Skip to content

Commit bc387a1

Browse files
committed
fix(workflow): confine project workflow scripts
1 parent c32b094 commit bc387a1

5 files changed

Lines changed: 99 additions & 7 deletions

File tree

skills/dynamic-workflows/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ devspace workflow call <runId> <callIndex>
2828
devspace workflow tui [runId]
2929
```
3030

31-
Named scripts: `.devspace/workflows/<name>.js` or `workflows/<name>.js`.
31+
Project named scripts live under `.devspace/workflows/<name>.js`.
3232

3333
## Script shape
3434

src/workflow-cli.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { executeWorkflow, mapEngineErrorKind } from "./workflow-engine.js";
1717
import {
1818
parseWorkflowArgFlagsResult,
1919
persistWorkflowScriptResult,
20+
readProjectWorkflowScriptFile,
2021
readWorkflowScriptFileResult,
2122
resolveNamedWorkflowScript,
2223
resolveWorkflowScriptFromPathOrNameResult,
@@ -416,7 +417,12 @@ export async function runWorkflowWorker(
416417
});
417418
return named.source;
418419
}
419-
return readFile(ref.scriptPath, "utf8");
420+
return (
421+
await readProjectWorkflowScriptFile({
422+
scriptPath: ref.scriptPath,
423+
workspaceRoot: claimed.workspaceRoot,
424+
})
425+
).source;
420426
},
421427
});
422428

src/workflow-errors.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import type {
1313
export class InvalidWorkflowInputError extends TaggedError(
1414
"InvalidWorkflowInputError",
1515
)<{
16-
code: "ambiguous_source" | "missing_source" | "invalid_name" | "invalid_argument";
16+
code:
17+
| "ambiguous_source"
18+
| "missing_source"
19+
| "invalid_name"
20+
| "invalid_argument"
21+
| "invalid_path";
1722
message: string;
1823
}>() {}
1924

src/workflow-files.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from "node:path";
55
import {
66
parseWorkflowArgFlags,
77
persistWorkflowScript,
8+
readProjectWorkflowScriptFile,
89
resolveNamedWorkflowScript,
910
resolveWorkflowScriptFromPathOrName,
1011
WorkflowPathError,
@@ -52,6 +53,33 @@ import { hashSource } from "./workflow-script.js";
5253
});
5354
assert.equal(named.origin, "named");
5455
assert.match(named.source, /named/);
56+
assert.equal(
57+
(
58+
await readProjectWorkflowScriptFile({
59+
scriptPath: join(dir, ".devspace", "workflows", "named.js"),
60+
workspaceRoot: dir,
61+
})
62+
).nameHint,
63+
"named",
64+
);
65+
await assert.rejects(
66+
() =>
67+
readProjectWorkflowScriptFile({
68+
scriptPath: path,
69+
workspaceRoot: dir,
70+
}),
71+
/must be inside/,
72+
);
73+
74+
await mkdir(join(dir, "workflows"), { recursive: true });
75+
await writeFile(
76+
join(dir, "workflows", "legacy.js"),
77+
"export const meta = { name: 'legacy', description: 'd' }\nreturn 3\n",
78+
);
79+
await assert.rejects(
80+
() => resolveNamedWorkflowScript({ name: "legacy", workspaceRoot: dir }),
81+
WorkflowPathError,
82+
);
5583

5684
await assert.rejects(
5785
() => resolveNamedWorkflowScript({ name: "missing", workspaceRoot: dir }),

src/workflow-files.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash, randomBytes } from "node:crypto";
2-
import { mkdir, readFile, writeFile } from "node:fs/promises";
2+
import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
33
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
44
import { Result, type Result as BetterResult } from "better-result";
55
import { hashSource } from "./workflow-script.js";
@@ -11,6 +11,7 @@ import {
1111
WorkflowFileReadError,
1212
WorkflowFileWriteError,
1313
} from "./workflow-errors.js";
14+
import { isPathInsideRoot } from "./roots.js";
1415

1516
export class WorkflowPathError extends Error {
1617
constructor(message: string) {
@@ -97,12 +98,65 @@ export async function readWorkflowScriptFileResult(
9798
});
9899
}
99100

101+
export async function readProjectWorkflowScriptFile(input: {
102+
scriptPath: string;
103+
workspaceRoot: string;
104+
}): Promise<ResolvedWorkflowScript> {
105+
const result = await readProjectWorkflowScriptFileResult(input);
106+
if (result.isErr()) throwPathCompatibilityError(result.error);
107+
return result.value;
108+
}
109+
110+
/** Resolve an explicit nested script only inside `<project>/.devspace/workflows`. */
111+
export async function readProjectWorkflowScriptFileResult(input: {
112+
scriptPath: string;
113+
workspaceRoot: string;
114+
}): Promise<BetterResult<ResolvedWorkflowScript, WorkflowFileResolveError>> {
115+
const projectWorkflowRoot = resolve(input.workspaceRoot, ".devspace", "workflows");
116+
const requestedPath = isAbsolute(input.scriptPath)
117+
? resolve(input.scriptPath)
118+
: resolve(projectWorkflowRoot, input.scriptPath);
119+
120+
if (!isPathInsideRoot(requestedPath, projectWorkflowRoot)) {
121+
return Result.err(
122+
new InvalidWorkflowInputError({
123+
code: "invalid_path",
124+
message: `Nested workflow script must be inside ${projectWorkflowRoot}`,
125+
}),
126+
);
127+
}
128+
129+
let canonicalRoot: string;
130+
let canonicalPath: string;
131+
try {
132+
[canonicalRoot, canonicalPath] = await Promise.all([
133+
realpath(projectWorkflowRoot),
134+
realpath(requestedPath),
135+
]);
136+
} catch (cause) {
137+
return Result.err(
138+
isFileNotFound(cause)
139+
? new WorkflowFileNotFoundError(requestedPath)
140+
: new WorkflowFileReadError(requestedPath, cause),
141+
);
142+
}
143+
144+
if (!isPathInsideRoot(canonicalPath, canonicalRoot)) {
145+
return Result.err(
146+
new InvalidWorkflowInputError({
147+
code: "invalid_path",
148+
message: `Nested workflow script resolves outside ${projectWorkflowRoot}`,
149+
}),
150+
);
151+
}
152+
return readWorkflowScriptFileResult(canonicalPath);
153+
}
154+
100155
/**
101156
* Resolve named workflow script.
102157
* Search order:
103158
* 1. `<cwd>/.devspace/workflows/<name>.js`
104-
* 2. `<cwd>/workflows/<name>.js`
105-
* 3. `<stateDir>/workflows/<name>.js` (if stateDir provided)
159+
* 2. `<stateDir>/workflows/<name>.js` (if stateDir provided)
106160
*/
107161
export async function resolveNamedWorkflowScript(input: {
108162
name: string;
@@ -130,7 +184,6 @@ export async function resolveNamedWorkflowScriptResult(input: {
130184
}
131185
const candidates = [
132186
join(input.workspaceRoot, ".devspace", "workflows", `${name}.js`),
133-
join(input.workspaceRoot, "workflows", `${name}.js`),
134187
];
135188
if (input.stateDir) {
136189
candidates.push(join(input.stateDir, "workflows", `${name}.js`));

0 commit comments

Comments
 (0)