@fission-ai/openspec 1.14.2 → 1.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,7 @@ import { getActiveChangeIds } from '../utils/item-discovery.js';
10
10
  import { describeNestedChange, findNestedChangesIn, } from '../utils/nested-change.js';
11
11
  import { getTaskProgressForChange } from '../utils/task-progress.js';
12
12
  import { FileSystemUtils } from '../utils/file-system.js';
13
+ import { assertDiscoveredSpecFile } from '../utils/spec-path-containment.js';
13
14
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
14
15
  import { foldRequirementName, parseDeltaSpec, } from '../core/parsers/requirement-blocks.js';
15
16
  import { extractRequirementBlock, diffRequirementBlock, buildRenameMap, } from '../utils/requirement-diff.js';
@@ -168,7 +169,7 @@ export class ChangeCommand {
168
169
  const mainSpecPath = path.join(mainSpecsDir, ...capability.split('/'), 'spec.md');
169
170
  let mainContent = null;
170
171
  try {
171
- FileSystemUtils.assertPathWithin(mainSpecsDir, mainSpecPath);
172
+ assertDiscoveredSpecFile(mainSpecsDir, mainSpecPath);
172
173
  mainContent = await fs.readFile(mainSpecPath, 'utf-8');
173
174
  }
174
175
  catch (error) {
@@ -5,7 +5,7 @@ import { Validator } from '../core/validation/validator.js';
5
5
  import { isInteractive } from '../utils/interactive.js';
6
6
  import { getSpecIds } from '../utils/item-discovery.js';
7
7
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
8
- import { FileSystemUtils } from '../utils/file-system.js';
8
+ import { assertDiscoveredSpecFile } from '../utils/spec-path-containment.js';
9
9
  const SPECS_DIR = 'openspec/specs';
10
10
  function assertSpecPath(specsDir, specPath) {
11
11
  const relativePath = path.relative(path.resolve(specsDir), path.resolve(specPath));
@@ -14,15 +14,7 @@ function assertSpecPath(specsDir, specPath) {
14
14
  path.isAbsolute(relativePath)) {
15
15
  throw new Error(`Path is outside the allowed directory: ${specPath}`);
16
16
  }
17
- try {
18
- // Preserve confined spec.md links, including links to a sibling capability.
19
- FileSystemUtils.assertPathWithin(specsDir, specPath);
20
- }
21
- catch {
22
- // A capability directory may intentionally be a monorepo symlink. Treat it
23
- // as the trust root while still rejecting a link outside that capability.
24
- FileSystemUtils.assertPathWithin(path.dirname(specPath), specPath);
25
- }
17
+ assertDiscoveredSpecFile(specsDir, specPath);
26
18
  }
27
19
  function parseSpecFromFile(specsDir, specPath, specId) {
28
20
  assertSpecPath(specsDir, specPath);
@@ -4,7 +4,7 @@ import { describeNestedChange, findNestedChangesIn, NESTED_CHANGE_ISSUE_MARKER,
4
4
  import { Validator } from '../core/validation/validator.js';
5
5
  import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
6
6
  import { resolveRootForCommand, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../core/root-selection.js';
7
- import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
7
+ import { canRenderSpinner, isInteractive, resolveNoInteractive } from '../utils/interactive.js';
8
8
  import { getSpecIds } from '../utils/item-discovery.js';
9
9
  import { getAvailableChanges } from './workflow/shared.js';
10
10
  import { nearestMatches } from '../utils/match.js';
@@ -356,7 +356,7 @@ export class ValidateCommand {
356
356
  }
357
357
  }
358
358
  async runBulkValidation(root, scope, opts) {
359
- const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
359
+ const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? ora('Validating...').start() : undefined;
360
360
  const [changeIds, specIds] = await Promise.all([
361
361
  scope.changes ? this.listChangeIds(root) : Promise.resolve([]),
362
362
  scope.specs ? getSpecIds(root.path) : Promise.resolve([]),
@@ -527,7 +527,7 @@ export class ValidateCommand {
527
527
  // List first (may throw on a real archive-read failure), then start the
528
528
  // spinner so a thrown error never leaves a spinner spinning.
529
529
  const ids = await this.listArchivedChangeIds(root);
530
- const spinner = !opts.json && !opts.noInteractive ? ora('Validating archived changes...').start() : undefined;
530
+ const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? ora('Validating archived changes...').start() : undefined;
531
531
  // The archive is append-only and can hold thousands of changes; a single
532
532
  // run resolves them all under one constant projectRoot (root.path), so
533
533
  // memoize the schema→glob lookup to avoid re-parsing the same schema.yaml
@@ -1,12 +1,29 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import fg from 'fast-glob';
3
+ import { expand } from 'brace-expansion';
4
+ import picomatch from 'picomatch';
5
+ import * as globby from 'tinyglobby';
4
6
  import { FileSystemUtils } from '../../utils/file-system.js';
5
7
  const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
6
8
  const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
7
- // braces <=3.0.3 (fast-glob > micromatch) has no depth guard (GHSA-vfj7-8cjw-p6xm),
8
- // so reject nesting no real artifact pattern needs before it reaches the parser.
9
+ const DUPLICATE_SLASH_RE = /(?!^)\/{2,}/gu;
10
+ // Deeply nested braces can overflow a recursive expander. Reject nesting no real
11
+ // artifact pattern needs before brace-expansion or tinyglobby see the pattern.
9
12
  const MAX_BRACE_NESTING = 16;
13
+ // Nesting depth alone does not bound the work: `{a,b}` repeated 20 times is
14
+ // only one level deep and expands to 1,048,576 patterns, which exhausts the
15
+ // heap. fast-glob never expanded that far because micromatch compiled the
16
+ // braces to a regex. No real artifact pattern needs more than a few hundred.
17
+ const MAX_BRACE_EXPANSIONS = 1024;
18
+ const ARTIFACT_GLOB_OPTIONS = {
19
+ absolute: true,
20
+ // Braces are expanded first so `{draft}` left behind stays a literal name.
21
+ braceExpansion: false,
22
+ // fast-glob migration: directory patterns must not grow a trailing `/**`.
23
+ expandDirectories: false,
24
+ followSymbolicLinks: true,
25
+ onlyFiles: true,
26
+ };
10
27
  function assertBraceNesting(pattern) {
11
28
  // Mirror the braces parser: `[...]` and quoted runs are literal, `(` nests like
12
29
  // `{`, and a closer only closes the innermost group of its own kind.
@@ -39,6 +56,166 @@ function assertBraceNesting(pattern) {
39
56
  }
40
57
  }
41
58
  }
59
+ function isDynamicSegment(segment) {
60
+ return segment.includes('*') || segment.includes('?') || segment.includes('[')
61
+ || EXTGLOB_RE.test(segment);
62
+ }
63
+ /**
64
+ * Static directory prefix of an expanded pattern, equivalent to the `base`
65
+ * fast-glob's generateTasks used to reject `../` before walking.
66
+ */
67
+ function globBase(pattern) {
68
+ const segments = pattern.split('/');
69
+ if (segments.length <= 1)
70
+ return '.';
71
+ const base = [];
72
+ for (const segment of segments.slice(0, -1)) {
73
+ if (segment === '')
74
+ continue;
75
+ if (isDynamicSegment(segment))
76
+ break;
77
+ base.push(segment);
78
+ }
79
+ // An absolute pattern must stay absolute, or `path.resolve(changeDir, base)`
80
+ // rebases `/etc/*` inside the change directory and the containment check
81
+ // passes for a pattern that globs the filesystem root.
82
+ if (pattern.startsWith('/'))
83
+ return `/${base.join('/')}`;
84
+ return base.length > 0 ? base.join('/') : '.';
85
+ }
86
+ // brace-expansion 5 leaves the rest of a pattern literal when a non-expanding
87
+ // group such as `{draft}` comes before a range. Expand one real group at a time.
88
+ // NUL-prefixed so no `generates` value can forge them: `resolveArtifactOutputPath`
89
+ // runs `path.join` first, which rejects a NUL byte. A single-character sentinel
90
+ // such as U+0001 is a legal filename character, and a pattern containing one
91
+ // would come back out of the expander as a brace.
92
+ const LITERAL_BRACE_OPEN = '\u0000<';
93
+ const LITERAL_BRACE_CLOSE = '\u0000>';
94
+ function hasTopLevelExpansion(body) {
95
+ let depth = 0;
96
+ for (let index = 0; index < body.length; index += 1) {
97
+ const char = body[index];
98
+ if (char === '{')
99
+ depth += 1;
100
+ else if (char === '}')
101
+ depth = Math.max(0, depth - 1);
102
+ else if (depth === 0) {
103
+ if (char === ',')
104
+ return true;
105
+ if (char === '.' && body[index + 1] === '.')
106
+ return true;
107
+ }
108
+ }
109
+ return false;
110
+ }
111
+ function findTopLevelExpansion(pattern) {
112
+ const stack = [];
113
+ for (let index = 0; index < pattern.length; index += 1) {
114
+ const char = pattern[index];
115
+ if (char === '{') {
116
+ stack.push(index);
117
+ }
118
+ else if (char === '}' && stack.length > 0) {
119
+ const open = stack.pop();
120
+ if (open === undefined)
121
+ continue;
122
+ // Only the outermost group that actually expands. brace-expansion expands
123
+ // its nested groups in one linear pass. Peeling the innermost `{a,b}` first
124
+ // turns `{a,{a,{a,b}}}` into a binary tree, so a 16-deep pattern does not
125
+ // finish before the test timeout.
126
+ if (stack.length > 0)
127
+ continue;
128
+ const body = pattern.slice(open + 1, index);
129
+ if (!hasTopLevelExpansion(body)) {
130
+ // `{prefix{a,b}}` has no separator of its own, so the outer braces stay
131
+ // literal. Keep scanning inside them; a nested group may still expand.
132
+ const nested = findTopLevelExpansion(body);
133
+ if (!nested)
134
+ continue;
135
+ return {
136
+ pre: `${pattern.slice(0, open + 1)}${nested.pre}`,
137
+ body: nested.body,
138
+ post: `${nested.post}${pattern.slice(index)}`,
139
+ };
140
+ }
141
+ return { pre: pattern.slice(0, open), body, post: pattern.slice(index + 1) };
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+ function assertExpansionBudget(pattern, count) {
147
+ if (count <= MAX_BRACE_EXPANSIONS)
148
+ return;
149
+ // A pattern this wide can run to thousands of characters; quote only its start.
150
+ const shown = pattern.length > 80 ? `${pattern.slice(0, 80)}...` : pattern;
151
+ throw new Error(`Artifact output pattern expands to more than ${MAX_BRACE_EXPANSIONS} patterns: ${shown}`);
152
+ }
153
+ function expandArtifactBraces(pattern) {
154
+ if (pattern.includes('\u0000')) {
155
+ throw new Error('Artifact output pattern contains a NUL byte');
156
+ }
157
+ const pending = [pattern];
158
+ const finished = [];
159
+ while (pending.length > 0) {
160
+ const current = pending.pop();
161
+ if (current === undefined)
162
+ break;
163
+ const group = findTopLevelExpansion(current);
164
+ if (!group) {
165
+ finished.push(current.replaceAll(LITERAL_BRACE_OPEN, '{').replaceAll(LITERAL_BRACE_CLOSE, '}'));
166
+ continue;
167
+ }
168
+ const wrapped = `{${group.body}}`;
169
+ // `max` makes this budget the binding one. Without it `{1..1000000000}`
170
+ // allocates brace-expansion's own 100,000-result default before the check
171
+ // below rejects it. `max` truncates silently, so ask for one result more
172
+ // than the budget allows: a result that long means the pattern is over
173
+ // budget rather than fully expanded. A truncated group never looks literal,
174
+ // because its first alternative is not the group itself.
175
+ const remaining = Math.max(MAX_BRACE_EXPANSIONS - (pending.length + finished.length), 0);
176
+ const alternatives = expand(wrapped, { max: remaining + 1 });
177
+ if (alternatives.length === 1 && alternatives[0] === wrapped) {
178
+ pending.push(`${group.pre}${LITERAL_BRACE_OPEN}${group.body}${LITERAL_BRACE_CLOSE}${group.post}`);
179
+ continue;
180
+ }
181
+ // Checked before the push so the budget bounds memory, not just the result.
182
+ assertExpansionBudget(pattern, pending.length + finished.length + alternatives.length);
183
+ for (const alternative of alternatives) {
184
+ pending.push(`${group.pre}${alternative}${group.post}`);
185
+ }
186
+ }
187
+ const normalized = finished
188
+ .map((value) => value.replace(DUPLICATE_SLASH_RE, '/'))
189
+ .filter((value) => value.length > 0);
190
+ return normalized.length > 0 ? normalized : [pattern];
191
+ }
192
+ /**
193
+ * One directory level. picomatch matches the segment; readdir keeps symlink
194
+ * entries tinyglobby would drop when followSymbolicLinks is false.
195
+ */
196
+ function listDirectoryMatches(directory, segment) {
197
+ let entries;
198
+ try {
199
+ entries = fs.readdirSync(directory, { withFileTypes: true });
200
+ }
201
+ catch (error) {
202
+ if (error.code === 'ENOENT')
203
+ return [];
204
+ throw error;
205
+ }
206
+ const isMatch = picomatch(segment, {
207
+ dot: false,
208
+ nobrace: true,
209
+ noglobstar: true,
210
+ posix: true,
211
+ });
212
+ const names = [];
213
+ for (const entry of entries) {
214
+ if (isMatch(entry.name))
215
+ names.push(entry.name);
216
+ }
217
+ return names;
218
+ }
42
219
  function hasBraceExpansion(pattern) {
43
220
  const openings = [];
44
221
  for (let index = 0; index < pattern.length; index += 1) {
@@ -94,12 +271,16 @@ function assertGlobDirectoryTraversal(changeDir, currentDir, directorySegments,
94
271
  // `**` may consume no directory at all.
95
272
  assertGlobDirectoryTraversal(changeDir, canonicalDir, directorySegments, segmentIndex + 1, visited, canonicalChangeDir, ancestors);
96
273
  }
97
- const matches = fg.sync(segment === '**' ? '*' : segment, {
98
- cwd: canonicalDir,
99
- onlyFiles: false,
100
- followSymbolicLinks: false,
101
- deep: 1,
102
- });
274
+ // `.` and `..` are not directory entries. Resolve them directly so a
275
+ // `**/../` pattern is confined before any glob runs.
276
+ if (segment === '' || segment === '.' || segment === '..') {
277
+ const candidate = path.resolve(canonicalDir, segment === '' ? '.' : segment);
278
+ const canonicalCandidate = FileSystemUtils.canonicalizeExistingPath(candidate);
279
+ FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalCandidate);
280
+ assertGlobDirectoryTraversal(changeDir, canonicalCandidate, directorySegments, segmentIndex + 1, visited, canonicalChangeDir, ancestors);
281
+ return;
282
+ }
283
+ const matches = listDirectoryMatches(canonicalDir, segment === '**' ? '*' : segment);
103
284
  for (const match of matches) {
104
285
  const candidate = path.join(canonicalDir, match);
105
286
  try {
@@ -139,27 +320,21 @@ export function resolveArtifactOutputs(changeDir, generates) {
139
320
  return [];
140
321
  }
141
322
  }
142
- const normalizedPattern = FileSystemUtils.toPosixPath(generates);
143
- const globOptions = {
323
+ const normalizedPattern = FileSystemUtils.toPosixPath(generates).replace(DUPLICATE_SLASH_RE, '/');
324
+ // Expand braces before any filesystem access. tinyglobby has no generateTasks,
325
+ // and a `../` alternative is invisible until the braces are expanded.
326
+ const patterns = expandArtifactBraces(normalizedPattern);
327
+ for (const pattern of patterns) {
328
+ FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, globBase(pattern)));
329
+ }
330
+ for (const pattern of patterns) {
331
+ assertGlobDirectoryTraversal(changeDir, changeDir, pattern.split('/').slice(0, -1));
332
+ }
333
+ const matches = globby
334
+ .globSync(patterns, {
144
335
  cwd: changeDir,
145
- onlyFiles: true,
146
- absolute: true,
147
- // Preserve linked artifact directories; confine traversal and concrete matches.
148
- followSymbolicLinks: true,
149
- };
150
- // Task generation expands braces without accessing the filesystem. Validate
151
- // every task base before globbing, including paths introduced by expansion.
152
- const tasks = fg.generateTasks(normalizedPattern, globOptions);
153
- for (const task of tasks) {
154
- FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, task.base));
155
- }
156
- for (const task of tasks) {
157
- for (const positivePattern of task.positive) {
158
- assertGlobDirectoryTraversal(changeDir, changeDir, positivePattern.split('/').slice(0, -1));
159
- }
160
- }
161
- const matches = fg
162
- .sync(normalizedPattern, globOptions)
336
+ ...ARTIFACT_GLOB_OPTIONS,
337
+ })
163
338
  .map((match) => {
164
339
  const normalizedMatch = path.normalize(match);
165
340
  FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
@@ -75,7 +75,7 @@ export class CompletionProvider {
75
75
  }
76
76
  // Fetch fresh data. The resolver (and the yaml and zod it parses schemas
77
77
  // with) loads only here: completing changes or specs never needs it, and
78
- // the artifact-graph barrel would also pull in fast-glob.
78
+ // the artifact-graph barrel would also pull in tinyglobby.
79
79
  const { listSchemas } = await import('../artifact-graph/resolver.js');
80
80
  const schemaNames = listSchemas(this.projectRoot);
81
81
  // Update cache
@@ -15,6 +15,7 @@ import { MarkdownParser } from './parsers/markdown-parser.js';
15
15
  import { MIN_PURPOSE_LENGTH, PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from './validation/constants.js';
16
16
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
17
17
  import { FileSystemUtils } from '../utils/file-system.js';
18
+ import { assertSpecFileWithin } from '../utils/spec-path-containment.js';
18
19
  import { matchLineEnding } from '../utils/line-endings.js';
19
20
  function isLexicallyWithin(allowedDirectory, targetPath) {
20
21
  const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
@@ -29,7 +30,7 @@ function resolveTrustedSpecPath(specsRoot, specPath, projectRoot) {
29
30
  }
30
31
  try {
31
32
  // Preserve spec.md links that remain inside the overall specs tree.
32
- FileSystemUtils.assertPathWithin(specsRoot, specPath);
33
+ assertSpecFileWithin(specsRoot, specPath);
33
34
  const root = FileSystemUtils.canonicalizeExistingPath(specsRoot);
34
35
  return {
35
36
  root,
@@ -52,7 +53,7 @@ function resolveTrustedSpecPath(specsRoot, specPath, projectRoot) {
52
53
  process.emitWarning(`Capability '${path.basename(path.dirname(specPath))}' links outside the project; writing to ${root}`, 'OpenSpecExternalSpecWrite');
53
54
  }
54
55
  const file = path.join(root, path.basename(specPath));
55
- FileSystemUtils.assertPathWithin(root, file);
56
+ assertSpecFileWithin(root, file);
56
57
  return { root, file };
57
58
  }
58
59
  }
@@ -60,7 +61,7 @@ function assertTrustedSpecPath(root, specPath) {
60
61
  if (FileSystemUtils.canonicalizeExistingPath(root) !== path.resolve(root)) {
61
62
  throw new Error(`Path is outside the allowed directory: ${specPath}`);
62
63
  }
63
- FileSystemUtils.assertPathWithin(root, specPath);
64
+ assertSpecFileWithin(root, specPath);
64
65
  }
65
66
  // -----------------------------------------------------------------------------
66
67
  // Public API
@@ -1042,6 +1043,14 @@ export async function pruneEmptyDirs(startDir, boundaryDir) {
1042
1043
  */
1043
1044
  export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
1044
1045
  assertTrustedSpecPath(update.targetRoot, update.target);
1046
+ // Pin what the assertion just checked. A capability's spec.md may be a link
1047
+ // to the file the project keeps elsewhere, so the write follows a link by
1048
+ // design - and resolving it once, here, is what keeps that from becoming a
1049
+ // redirect: a link repointed after this line, including from inside
1050
+ // `beforeMutate`, cannot send the write to a file nothing verified. The
1051
+ // bytes still land in the same place a path-based write would have put them,
1052
+ // which keeps the in-place semantics below intact.
1053
+ const verifiedTarget = FileSystemUtils.canonicalizeExistingPath(update.target);
1045
1054
  // Create target directory if needed
1046
1055
  const targetDir = path.dirname(update.target);
1047
1056
  await fs.mkdir(targetDir, { recursive: true });
@@ -1051,17 +1060,26 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
1051
1060
  // requirement moved. A spec that does not exist yet stays LF.
1052
1061
  // Only a missing file means "no convention to match". Swallowing every error
1053
1062
  // would read an existing but unreadable spec as absent and rewrite it as LF.
1054
- const previous = await fs.readFile(update.target, 'utf-8').catch((error) => {
1063
+ const previous = await fs.readFile(verifiedTarget, 'utf-8').catch((error) => {
1055
1064
  if (error.code === 'ENOENT')
1056
1065
  return undefined;
1057
1066
  throw error;
1058
1067
  });
1059
1068
  const toWrite = previous === undefined ? rebuilt : matchLineEnding(rebuilt, previous);
1060
1069
  await options.beforeMutate?.();
1070
+ // `beforeMutate` awaits arbitrary work, so it is the widest gap between the
1071
+ // check above and the write below. Re-check there, and refuse unless the
1072
+ // target still resolves to the very file that was verified: pinning a
1073
+ // pathname is not enough on its own, because the pinned path can itself be
1074
+ // replaced by a link to somewhere else.
1075
+ assertTrustedSpecPath(update.targetRoot, update.target);
1076
+ if (FileSystemUtils.canonicalizeExistingPath(update.target) !== verifiedTarget) {
1077
+ throw new Error(`Spec target for '${update.id}' changed before it could be written: ${update.target}`);
1078
+ }
1061
1079
  // Preserve the established in-place write semantics: symlink referents,
1062
1080
  // hard-linked specs, ACLs, extended attributes, and filesystems without hard
1063
1081
  // links must continue to behave as they did before capability retirement.
1064
- await fs.writeFile(update.target, toWrite);
1082
+ await fs.writeFile(verifiedTarget, toWrite);
1065
1083
  if (options.silent)
1066
1084
  return;
1067
1085
  const specName = update.id;
@@ -14,8 +14,8 @@ const PLANNING_COMPLETE_HANDOFF = optionalWorkflow('apply', 'You can now impleme
14
14
  */
15
15
  const PARTIAL_ARTIFACT_CHECK = `**Before anything else, finish a partly written artifact**:
16
16
  - An artifact whose output is a glob pattern (e.g. \`specs/**/*.md\`) reads \`done\` as soon as one matching file exists, so a run interrupted while writing its files leaves it \`done\` with some still missing
17
- - For each such \`done\` artifact, first get its instructions and read any completed dependencies needed to determine the expected files. Then compare those files with \`artifactPaths.<id>.existingOutputPaths\`. For spec-driven's \`specs\`, read the proposal and check for one \`specs/<capability-path>/spec.md\` per capability it lists; resolve each expected path against \`changeRoot\` before comparing path identity
18
- - If any are missing, write only the missing files, and STOP - that counts as this invocation's ONE artifact. If you cannot tell whether a file was left out on purpose, ask the user`;
17
+ - For each such \`done\` artifact, first get its instructions and read any completed dependencies needed to determine the expected files. Then compare those files with \`artifactPaths.<id>.existingOutputPaths\`. For spec-driven's \`specs\`, read the proposal and check for one \`specs/<capability-path>/spec.md\` per capability it lists; resolve each expected path against \`changeRoot\` before comparing path identity, and verify each one remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists
18
+ - Never write an expected path that fails that check; stop and tell the user. If any are missing, write only the missing files, each at its checked path - when the \`instruction\` field delegates creation to a skill or command, invoke it only if it can write each file to its checked path; otherwise stop and tell the user - and STOP - that counts as this invocation's ONE artifact. If you cannot tell whether a file was left out on purpose, ask the user`;
19
19
  export function getContinueChangeSkillTemplate() {
20
20
  return {
21
21
  name: 'openspec-continue-change',
@@ -88,10 +88,13 @@ ${PROJECT_ROOT_GUARD}
88
88
  - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact
89
89
  - **Create the artifact file**:
90
90
  - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
91
- - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
92
- - Otherwise use \`template\` as the structure - fill in its sections
93
- - Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file
94
- - Write to the \`resolvedOutputPath\` specified in instructions. If it is a glob pattern, choose the concrete file path using the schema instruction and the change's context
91
+ - Choose the output path before creating anything. If the \`resolvedOutputPath\` specified in instructions is a glob pattern, choose a concrete path inside \`changeRoot\` using the schema instruction and the change's context, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
92
+ - If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user
93
+ - Otherwise, write it yourself:
94
+ - Use \`template\` as the structure - fill in its sections
95
+ - Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file
96
+ - Write the file to that output path
97
+ - Either way, verify the artifact file exists at that output path
95
98
  - Show what was created and what's now unlocked
96
99
  - STOP after creating ONE artifact
97
100
 
@@ -119,7 +122,7 @@ After each invocation, show:
119
122
 
120
123
  The artifact types and their purpose depend on the schema. The \`instruction\` field from the instructions output is the authoritative guidance for each artifact - follow it even when the artifact has a familiar name (proposal.md, tasks.md, etc.), since custom schemas may define different content or a different process for the same file names.
121
124
 
122
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly.
125
+ If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there.
123
126
 
124
127
  **Guardrails**
125
128
  - Create ONE artifact per invocation
@@ -210,10 +213,13 @@ ${PROJECT_ROOT_GUARD}
210
213
  - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact
211
214
  - **Create the artifact file**:
212
215
  - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
213
- - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
214
- - Otherwise use \`template\` as the structure - fill in its sections
215
- - Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file
216
- - Write to the \`resolvedOutputPath\` specified in instructions. If it is a glob pattern, choose the concrete file path using the schema instruction and the change's context
216
+ - Choose the output path before creating anything. If the \`resolvedOutputPath\` specified in instructions is a glob pattern, choose a concrete path inside \`changeRoot\` using the schema instruction and the change's context, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
217
+ - If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user
218
+ - Otherwise, write it yourself:
219
+ - Use \`template\` as the structure - fill in its sections
220
+ - Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file
221
+ - Write the file to that output path
222
+ - Either way, verify the artifact file exists at that output path
217
223
  - Show what was created and what's now unlocked
218
224
  - STOP after creating ONE artifact
219
225
 
@@ -241,7 +247,7 @@ After each invocation, show:
241
247
 
242
248
  The artifact types and their purpose depend on the schema. The \`instruction\` field from the instructions output is the authoritative guidance for each artifact - follow it even when the artifact has a familiar name (proposal.md, tasks.md, etc.), since custom schemas may define different content or a different process for the same file names.
243
249
 
244
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly.
250
+ If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there.
245
251
 
246
252
  **Guardrails**
247
253
  - Create ONE artifact per invocation
@@ -156,7 +156,7 @@ If the user asks you to capture the exploration as a new change, that request is
156
156
 
157
157
  1. Run \`openspec new change "<name>"\` (with \`--store <id>\` when applicable) before creating any artifacts. Never create a new change directory under \`openspec/changes/\` by hand; the CLI scaffold creates required metadata such as \`.openspec.yaml\`. Keep the selected \`--store <id>\` on every applicable follow-up \`status\` and \`instructions\` command.
158
158
  2. Run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is \`ready\`, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own \`instruction\` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run \`openspec instructions "<prerequisite-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) for that prerequisite whether it is \`ready\` or \`blocked\`. If its own \`instruction\` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves.
159
- 3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to \`resolvedOutputPath\`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists.
159
+ 3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. Choose the output path before creating anything: when \`resolvedOutputPath\` is a glob, use the instruction to choose a concrete path inside \`changeRoot\` and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
160
160
  4. After creating each artifact, re-run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) and continue until every requested artifact is \`done\`, \`skipped\`, or was deliberately skipped because its own \`instruction\` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still \`blocked\` only because you deliberately skipped a conditional prerequisite, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture.
161
161
 
162
162
  Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: ${CAPTURE_PLANNING_HANDOFF}, and ${CAPTURE_APPLY_HANDOFF}. Capturing artifacts never starts implementing them.
@@ -491,7 +491,7 @@ If the user asks you to capture the exploration as a new change, that request is
491
491
 
492
492
  1. Run \`openspec new change "<name>"\` (with \`--store <id>\` when applicable) before creating any artifacts. Never create a new change directory under \`openspec/changes/\` by hand; the CLI scaffold creates required metadata such as \`.openspec.yaml\`. Keep the selected \`--store <id>\` on every applicable follow-up \`status\` and \`instructions\` command.
493
493
  2. Run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is \`ready\`, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own \`instruction\` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run \`openspec instructions "<prerequisite-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) for that prerequisite whether it is \`ready\` or \`blocked\`. If its own \`instruction\` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves.
494
- 3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to \`resolvedOutputPath\`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists.
494
+ 3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. Choose the output path before creating anything: when \`resolvedOutputPath\` is a glob, use the instruction to choose a concrete path inside \`changeRoot\` and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
495
495
  4. After creating each artifact, re-run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) and continue until every requested artifact is \`done\`, \`skipped\`, or was deliberately skipped because its own \`instruction\` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still \`blocked\` only because you deliberately skipped a conditional prerequisite, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture.
496
496
 
497
497
  Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: ${CAPTURE_PLANNING_HANDOFF}, and ${CAPTURE_APPLY_HANDOFF}. Capturing artifacts never starts implementing them.
@@ -73,8 +73,9 @@ ${PROJECT_ROOT_GUARD}
73
73
  - Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
74
74
  - Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
75
75
  - Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
76
- - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
77
- - Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
76
+ - Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
77
+ - If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
78
+ - Otherwise create the artifact file using \`template\` as the structure and write it to that output path
78
79
  - Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
79
80
  - Show brief progress: "✓ Created <artifact-id>"
80
81
 
@@ -108,7 +109,7 @@ After completing all artifacts, summarize:
108
109
  **Artifact Creation Guidelines**
109
110
 
110
111
  - Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
111
- - If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
112
+ - If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
112
113
  - The schema defines what each artifact should contain - follow it
113
114
  - Read dependency artifacts for context before creating new ones
114
115
  - Use \`template\` as the structure for your output file - fill in its sections
@@ -191,8 +192,9 @@ ${PROJECT_ROOT_GUARD}
191
192
  - Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
192
193
  - Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
193
194
  - Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
194
- - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
195
- - Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
195
+ - Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
196
+ - If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
197
+ - Otherwise create the artifact file using \`template\` as the structure and write it to that output path
196
198
  - Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
197
199
  - Show brief progress: "✓ Created <artifact-id>"
198
200
 
@@ -226,7 +228,7 @@ After completing all artifacts, summarize:
226
228
  **Artifact Creation Guidelines**
227
229
 
228
230
  - Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
229
- - If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
231
+ - If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
230
232
  - The schema defines what each artifact should contain - follow it
231
233
  - Read dependency artifacts for context before creating new ones
232
234
  - Use \`template\` as the structure for your output file - fill in its sections
@@ -119,8 +119,9 @@ ${PROJECT_ROOT_GUARD}
119
119
  - Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
120
120
  - Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
121
121
  - Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
122
- - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
123
- - Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
122
+ - Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
123
+ - If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
124
+ - Otherwise create the artifact file using \`template\` as the structure and write it to that output path
124
125
  - Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
125
126
  - Show brief progress: "Created <artifact-id>"
126
127
 
@@ -154,7 +155,7 @@ After completing all artifacts, summarize:
154
155
  **Artifact Creation Guidelines**
155
156
 
156
157
  - Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
157
- - If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
158
+ - If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
158
159
  - The schema defines what each artifact should contain - follow it
159
160
  - Read dependency artifacts for context before creating new ones
160
161
  - Use \`template\` as the structure for your output file - fill in its sections
@@ -283,8 +284,9 @@ ${PROJECT_ROOT_GUARD}
283
284
  - Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
284
285
  - Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
285
286
  - Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
286
- - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
287
- - Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
287
+ - Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
288
+ - If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
289
+ - Otherwise create the artifact file using \`template\` as the structure and write it to that output path
288
290
  - Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
289
291
  - Show brief progress: "Created <artifact-id>"
290
292
 
@@ -318,7 +320,7 @@ After completing all artifacts, summarize:
318
320
  **Artifact Creation Guidelines**
319
321
 
320
322
  - Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
321
- - If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
323
+ - If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
322
324
  - The schema defines what each artifact should contain - follow it
323
325
  - Read dependency artifacts for context before creating new ones
324
326
  - Use \`template\` as the structure for your output file - fill in its sections
@@ -9,6 +9,7 @@ import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractR
9
9
  import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, } from '../parsers/requirement-text.js';
10
10
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
12
+ import { assertDiscoveredSpecFile } from '../../utils/spec-path-containment.js';
12
13
  import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
13
14
  import { METADATA_FILENAME, formatUnknownChangeMetadataKeysMessage, readSkipSpecsMarker, readUnknownChangeMetadataKeys, resolveSchemaForChange, } from '../../utils/change-metadata.js';
14
15
  import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
@@ -314,8 +315,7 @@ export class Validator {
314
315
  // authoring time instead of days later at archive time (#1477).
315
316
  if (options.mainSpecsDir && plan.modified.length > 0) {
316
317
  const mainSpecFile = path.join(options.mainSpecsDir, ...specId.split('/'), 'spec.md');
317
- FileSystemUtils.assertPathWithin(path.dirname(mainSpecFile), mainSpecFile);
318
- issues.push(...(await this.findScenarioLossIssues(plan.modified, plan.renamed, mainSpecFile, entryPath, path.dirname(mainSpecFile))));
318
+ issues.push(...(await this.findScenarioLossIssues(plan.modified, plan.renamed, mainSpecFile, entryPath, options.mainSpecsDir)));
319
319
  }
320
320
  // Validate REMOVED (names only)
321
321
  for (const name of plan.removed) {
@@ -606,7 +606,7 @@ export class Validator {
606
606
  */
607
607
  async findScenarioLossIssues(modified, renamed, mainSpecFile, entryPath, mainSpecRoot) {
608
608
  let mainContent;
609
- FileSystemUtils.assertPathWithin(mainSpecRoot, mainSpecFile);
609
+ assertDiscoveredSpecFile(mainSpecRoot, mainSpecFile);
610
610
  try {
611
611
  mainContent = await fs.readFile(mainSpecFile, 'utf-8');
612
612
  }
@@ -1,7 +1,7 @@
1
1
  export declare class FileSystemUtils {
2
2
  /**
3
3
  * Converts a path to use forward slashes (POSIX style).
4
- * Essential for cross-platform compatibility with glob libraries like fast-glob.
4
+ * Essential for cross-platform compatibility with glob libraries like tinyglobby.
5
5
  */
6
6
  static toPosixPath(p: string): string;
7
7
  /**
@@ -65,7 +65,7 @@ function findMarkerIndex(content, marker, fromIndex = 0) {
65
65
  export class FileSystemUtils {
66
66
  /**
67
67
  * Converts a path to use forward slashes (POSIX style).
68
- * Essential for cross-platform compatibility with glob libraries like fast-glob.
68
+ * Essential for cross-platform compatibility with glob libraries like tinyglobby.
69
69
  */
70
70
  static toPosixPath(p) {
71
71
  return p.replace(/\\/g, '/');
@@ -67,4 +67,19 @@ export declare function confirmPrompt(prompt: ConfirmPrompt, io?: {
67
67
  input?: Readable;
68
68
  output?: Writable;
69
69
  }): Promise<boolean>;
70
+ /**
71
+ * Whether a spinner can safely render to `stream`.
72
+ *
73
+ * ora decides how many lines to erase with `ceil(width / stream.columns)`, so a
74
+ * TTY that reports zero columns makes that count `Infinity` and `clear()` never
75
+ * ends: the spinner emits cursor-up/clear-line escapes as fast as it can and the
76
+ * command never finishes (#2106). Zero-width TTYs are real — a pty inherited
77
+ * from a pre-commit runner such as lefthook, or `script -qec` — and nothing can
78
+ * be drawn in zero columns anyway, so refuse the spinner and let the caller fall
79
+ * back to its plain output.
80
+ */
81
+ export declare function canRenderSpinner(stream?: {
82
+ isTTY?: boolean;
83
+ columns?: number;
84
+ }): boolean;
70
85
  //# sourceMappingURL=interactive.d.ts.map
@@ -153,4 +153,22 @@ function readYesNo(prompt, input, output) {
153
153
  });
154
154
  });
155
155
  }
156
+ /**
157
+ * Whether a spinner can safely render to `stream`.
158
+ *
159
+ * ora decides how many lines to erase with `ceil(width / stream.columns)`, so a
160
+ * TTY that reports zero columns makes that count `Infinity` and `clear()` never
161
+ * ends: the spinner emits cursor-up/clear-line escapes as fast as it can and the
162
+ * command never finishes (#2106). Zero-width TTYs are real — a pty inherited
163
+ * from a pre-commit runner such as lefthook, or `script -qec` — and nothing can
164
+ * be drawn in zero columns anyway, so refuse the spinner and let the caller fall
165
+ * back to its plain output.
166
+ */
167
+ export function canRenderSpinner(stream = process.stderr) {
168
+ // A non-TTY stream never enters the clear loop: ora disables itself and
169
+ // prints one plain line instead.
170
+ if (!stream.isTTY)
171
+ return true;
172
+ return (stream.columns ?? 0) > 0;
173
+ }
156
174
  //# sourceMappingURL=interactive.js.map
@@ -10,10 +10,10 @@ export interface DiscoveredSpec {
10
10
  * are found (#1353). A `spec.md` sitting directly in the root is ignored,
11
11
  * matching the historical requirement that specs live in a capability folder.
12
12
  * Dot-directories are skipped and symlinked directories are not followed.
13
- * An in-capability symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the
14
- * artifact graph's globs both count it as content, so dropping it here would
15
- * silently lose the delta on archive. A link outside its capability is
16
- * rejected and a dangling link is skipped. Results are sorted by id.
13
+ * A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
14
+ * graph's globs both count it as content, so dropping it here would silently
15
+ * lose the delta on archive. `assertSpecFilePathAllowed` decides how far such
16
+ * a link may point; a dangling link is skipped. Results are sorted by id.
17
17
  *
18
18
  * A missing root (ENOENT) yields an empty list, but any other read failure
19
19
  * (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
@@ -1,27 +1,17 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
- import { FileSystemUtils } from './file-system.js';
3
+ import { assertDiscoveredSpecFile } from './spec-path-containment.js';
4
4
  import { parseDeltaSpec } from '../core/parsers/requirement-blocks.js';
5
- function assertDiscoveredSpecPath(specsRoot, capabilityDir, specFile) {
6
- try {
7
- FileSystemUtils.assertPathWithin(specsRoot, specFile);
8
- }
9
- catch {
10
- // Direct capability directories may intentionally be external monorepo
11
- // links. In that case, confine the file to the capability itself.
12
- FileSystemUtils.assertPathWithin(capabilityDir, specFile);
13
- }
14
- }
15
5
  /**
16
6
  * Recursively discover every `spec.md` under a specs root, so both the flat
17
7
  * `specs/<id>/spec.md` layout and nested `specs/<area>/<id>/spec.md` layouts
18
8
  * are found (#1353). A `spec.md` sitting directly in the root is ignored,
19
9
  * matching the historical requirement that specs live in a capability folder.
20
10
  * Dot-directories are skipped and symlinked directories are not followed.
21
- * An in-capability symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the
22
- * artifact graph's globs both count it as content, so dropping it here would
23
- * silently lose the delta on archive. A link outside its capability is
24
- * rejected and a dangling link is skipped. Results are sorted by id.
11
+ * A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
12
+ * graph's globs both count it as content, so dropping it here would silently
13
+ * lose the delta on archive. `assertSpecFilePathAllowed` decides how far such
14
+ * a link may point; a dangling link is skipped. Results are sorted by id.
25
15
  *
26
16
  * A missing root (ENOENT) yields an empty list, but any other read failure
27
17
  * (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
@@ -49,13 +39,13 @@ export async function discoverSpecFiles(specsRoot) {
49
39
  else if (entry.name === 'spec.md' && segments.length > 0) {
50
40
  const specFile = path.join(dir, entry.name);
51
41
  if (entry.isFile()) {
52
- assertDiscoveredSpecPath(specsRoot, dir, specFile);
42
+ assertDiscoveredSpecFile(specsRoot, specFile);
53
43
  results.push({ id: segments.join('/'), specFile });
54
44
  }
55
45
  else if (entry.isSymbolicLink()) {
56
46
  try {
57
47
  if ((await fs.stat(specFile)).isFile()) {
58
- assertDiscoveredSpecPath(specsRoot, dir, specFile);
48
+ assertDiscoveredSpecFile(specsRoot, specFile);
59
49
  results.push({ id: segments.join('/'), specFile });
60
50
  }
61
51
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Containment for a `spec.md` under a trust root already established for it:
3
+ * inside the root, or a link out of the root that stays inside the project.
4
+ *
5
+ * Deliberately does not re-derive a trust root from the file's own capability
6
+ * directory. Callers use this to recheck a path immediately before reading or
7
+ * writing it, and a capability directory swapped for a link since the path was
8
+ * resolved has to be refused rather than trusted afresh.
9
+ */
10
+ export declare function assertSpecFileWithin(root: string, specFile: string): void;
11
+ /**
12
+ * Containment for a `spec.md` as it is discovered, where the capability
13
+ * directory itself may deliberately be a link into another repository and is
14
+ * then the file's own trust root.
15
+ */
16
+ export declare function assertDiscoveredSpecFile(specsRoot: string, specFile: string): void;
17
+ //# sourceMappingURL=spec-path-containment.d.ts.map
@@ -0,0 +1,122 @@
1
+ import * as nodeFs from 'fs';
2
+ import path from 'path';
3
+ import { OPENSPEC_DIR_NAME } from '../core/config.js';
4
+ import { FileSystemUtils } from './file-system.js';
5
+ /**
6
+ * The project a specs root belongs to, or null when it cannot be named.
7
+ *
8
+ * Every specs root OpenSpec reads is addressed through the project's
9
+ * `openspec` directory - `<project>/openspec/specs` for main specs and
10
+ * `<project>/openspec/changes/<change>/specs` (or `.../changes/archive/...`)
11
+ * for a change's deltas - so the project is the parent of that `openspec`
12
+ * directory.
13
+ *
14
+ * The directory below the match has to be `specs` or `changes`, which is what
15
+ * makes this a planning root rather than a directory that merely shares the
16
+ * name: a store kept at `~/openspec/my-store` would otherwise hand out `~` as
17
+ * its boundary. A specs root reached some other way (a store given by path)
18
+ * has no project to name, and containment stays at the specs root.
19
+ */
20
+ function findProjectRoot(specsRoot) {
21
+ let current = path.resolve(specsRoot);
22
+ let below = null;
23
+ while (true) {
24
+ const parent = path.dirname(current);
25
+ if (parent === current) {
26
+ return null;
27
+ }
28
+ if (path.basename(current) === OPENSPEC_DIR_NAME &&
29
+ (below === 'specs' || below === 'changes')) {
30
+ return parent;
31
+ }
32
+ below = path.basename(current);
33
+ current = parent;
34
+ }
35
+ }
36
+ /** Path-segment containment on the paths as written, without resolving links. */
37
+ function isLexicallyWithin(allowedDirectory, targetPath) {
38
+ const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
39
+ return (relative === '' ||
40
+ (relative !== '..' &&
41
+ !relative.startsWith(`..${path.sep}`) &&
42
+ !path.isAbsolute(relative)));
43
+ }
44
+ /**
45
+ * A project may keep a capability's spec file elsewhere - beside the product
46
+ * documentation it belongs to - and leave a relative symlink at
47
+ * `openspec/specs/<capability>/spec.md`, so OpenSpec and the docs read and
48
+ * write the same bytes. Accept such a link, and refuse one that leaves the
49
+ * project, so a link swapped underneath a repo cannot turn a spec read or an
50
+ * archive write into an arbitrary file access.
51
+ *
52
+ * `outsideError` is rethrown when the file is not a link at all, so a path
53
+ * that simply escapes its root keeps reporting that.
54
+ */
55
+ function assertLinkedSpecFileInProject(root, specFile, outsideError) {
56
+ const projectRoot = findProjectRoot(root);
57
+ if (projectRoot === null) {
58
+ throw outsideError;
59
+ }
60
+ const resolvedFile = path.resolve(specFile);
61
+ let isLink = false;
62
+ try {
63
+ isLink = nodeFs.lstatSync(resolvedFile).isSymbolicLink();
64
+ }
65
+ catch {
66
+ isLink = false;
67
+ }
68
+ if (!isLink) {
69
+ throw outsideError;
70
+ }
71
+ // Only where the link points is the project's choice. The directory holding
72
+ // it must still be reached from inside the root.
73
+ FileSystemUtils.assertPathWithin(root, path.dirname(resolvedFile));
74
+ // Canonicalizes through the link, so a target outside the project is refused
75
+ // and a dangling link - which cannot be proven contained - fails loudly.
76
+ FileSystemUtils.assertPathWithin(projectRoot, resolvedFile);
77
+ }
78
+ /**
79
+ * Containment for a `spec.md` under a trust root already established for it:
80
+ * inside the root, or a link out of the root that stays inside the project.
81
+ *
82
+ * Deliberately does not re-derive a trust root from the file's own capability
83
+ * directory. Callers use this to recheck a path immediately before reading or
84
+ * writing it, and a capability directory swapped for a link since the path was
85
+ * resolved has to be refused rather than trusted afresh.
86
+ */
87
+ export function assertSpecFileWithin(root, specFile) {
88
+ try {
89
+ FileSystemUtils.assertPathWithin(root, specFile);
90
+ }
91
+ catch (error) {
92
+ assertLinkedSpecFileInProject(root, specFile, error);
93
+ }
94
+ }
95
+ /**
96
+ * Containment for a `spec.md` as it is discovered, where the capability
97
+ * directory itself may deliberately be a link into another repository and is
98
+ * then the file's own trust root.
99
+ */
100
+ export function assertDiscoveredSpecFile(specsRoot, specFile) {
101
+ const capabilityDir = path.dirname(path.resolve(specFile));
102
+ try {
103
+ FileSystemUtils.assertPathWithin(specsRoot, specFile);
104
+ return;
105
+ }
106
+ catch (error) {
107
+ // The capability directory is the file's own trust root only while it is
108
+ // addressed from inside the specs root: the link it may be is deliberate,
109
+ // a `..` out of the root is not.
110
+ if (isLexicallyWithin(specsRoot, capabilityDir)) {
111
+ try {
112
+ FileSystemUtils.assertPathWithin(capabilityDir, specFile);
113
+ return;
114
+ }
115
+ catch {
116
+ // Not a file of its own capability either; the link rule decides.
117
+ }
118
+ }
119
+ assertLinkedSpecFileInProject(specsRoot, specFile, error);
120
+ }
121
+ }
122
+ //# sourceMappingURL=spec-path-containment.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.14.2",
3
+ "version": "1.14.3",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -44,6 +44,7 @@
44
44
  "@changesets/changelog-github": "^1.0.0",
45
45
  "@changesets/cli": "^3.0.1",
46
46
  "@types/node": "^20.19.43",
47
+ "@types/picomatch": "^4.0.3",
47
48
  "@vitest/ui": "^4.1.11",
48
49
  "eslint": "^10.5.0",
49
50
  "smol-toml": "^1.7.1",
@@ -54,12 +55,14 @@
54
55
  "dependencies": {
55
56
  "@inquirer/core": "^12.0.0",
56
57
  "@inquirer/prompts": "^8.5.2",
58
+ "brace-expansion": "^5.0.12",
57
59
  "chalk": "^5.6.2",
58
60
  "commander": "^14.0.0",
59
61
  "cross-spawn": "7.0.6",
60
62
  "diff": "^9.0.0",
61
- "fast-glob": "^3.3.3",
62
63
  "ora": "^9.4.1",
64
+ "picomatch": "^4.0.4",
65
+ "tinyglobby": "^0.2.17",
63
66
  "yaml": "^2.8.3",
64
67
  "zod": "^4.4.3"
65
68
  },