@tagma/sdk 0.4.10 → 0.4.12

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.
@@ -1,129 +1,145 @@
1
- import { watch } from 'chokidar';
2
- import { resolve, dirname } from 'path';
3
- import { mkdir } from 'fs/promises';
4
- import type { TriggerPlugin, TriggerContext } from '../types';
5
- import { parseDuration, validatePath } from '../utils';
6
- import { TriggerTimeoutError } from '../engine';
7
-
8
- const IS_WINDOWS = process.platform === 'win32';
9
-
10
- function pathsEqual(a: string, b: string): boolean {
11
- return IS_WINDOWS ? a.toLowerCase() === b.toLowerCase() : a === b;
12
- }
13
-
14
- export const FileTrigger: TriggerPlugin = {
15
- name: 'file',
16
- schema: {
17
- description: 'Wait for a file to appear or be modified before the task runs.',
18
- fields: {
19
- path: {
20
- type: 'path',
21
- required: true,
22
- description: 'Path to the file to watch (relative to workDir or absolute).',
23
- placeholder: 'e.g. build/output.json',
24
- },
25
- timeout: {
26
- type: 'duration',
27
- description: 'Maximum wait time (e.g. 30s, 5m). Omit or 0 to wait indefinitely.',
28
- placeholder: '30s',
29
- },
30
- },
31
- },
32
-
33
- watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
34
- const filePath = config.path as string;
35
- if (!filePath) throw new Error(`file trigger: "path" is required`);
36
-
37
- const safePath = validatePath(filePath, ctx.workDir);
38
- const timeoutMs = config.timeout != null ? parseDuration(String(config.timeout)) : 0;
39
-
40
- return new Promise(async (resolve_p, reject) => {
41
- if (ctx.signal.aborted) {
42
- reject(new Error('Pipeline aborted'));
43
- return;
44
- }
45
-
46
- let settled = false;
47
- let timer: ReturnType<typeof setTimeout> | null = null;
48
-
49
- // Ensure the parent directory exists so the watcher doesn't fail
50
- // with ENOENT for nested paths like `build/output/result.json`.
51
- const dir = dirname(safePath);
52
- try {
53
- await mkdir(dir, { recursive: true });
54
- } catch { /* best effort — dir may already exist */ }
55
-
56
- const watcher = watch(dir, {
57
- ignoreInitial: true,
58
- depth: 0,
59
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
60
- });
61
-
62
- const cleanup = () => {
63
- if (settled) return;
64
- settled = true;
65
- watcher.close().catch(() => { /* ignore */ });
66
- if (timer) clearTimeout(timer);
67
- ctx.signal.removeEventListener('abort', onAbort);
68
- };
69
-
70
- const onAbort = () => {
71
- cleanup();
72
- reject(new Error('Pipeline aborted'));
73
- };
74
-
75
- watcher.on('add', (addedPath: string) => {
76
- if (settled) return;
77
- if (pathsEqual(resolve(addedPath), safePath)) {
78
- cleanup();
79
- resolve_p({ path: safePath });
80
- }
81
- });
82
-
83
- // Also fire on 'change' so that overwriting an existing file is detected.
84
- // Without this, upstream tasks that truncate-and-rewrite a file emit only
85
- // a 'change' event and the downstream trigger would never resolve.
86
- watcher.on('change', (changedPath: string) => {
87
- if (settled) return;
88
- if (pathsEqual(resolve(changedPath), safePath)) {
89
- cleanup();
90
- resolve_p({ path: safePath });
91
- }
92
- });
93
-
94
- watcher.on('error', (err: unknown) => {
95
- if (settled) return;
96
- cleanup();
97
- reject(new Error(`file trigger watch error: ${err instanceof Error ? err.message : String(err)}`));
98
- });
99
-
100
- // After the watcher finishes its initial scan, check if the file already exists.
101
- // Doing this inside 'ready' eliminates the race window between existence check
102
- // and watcher startup, so we neither miss events nor double-resolve.
103
- watcher.on('ready', () => {
104
- if (settled) return;
105
- Bun.file(safePath).exists().then((exists) => {
106
- if (settled) return;
107
- if (exists) {
108
- cleanup();
109
- resolve_p({ path: safePath });
110
- }
111
- }).catch((err: unknown) => {
112
- if (settled) return;
113
- cleanup();
114
- reject(new Error(`file trigger existence check failed: ${err instanceof Error ? err.message : String(err)}`));
115
- });
116
- });
117
-
118
- if (timeoutMs > 0) {
119
- timer = setTimeout(() => {
120
- if (settled) return;
121
- cleanup();
122
- reject(new TriggerTimeoutError(`file trigger timeout: ${filePath} did not appear within ${config.timeout}`));
123
- }, timeoutMs);
124
- }
125
-
126
- ctx.signal.addEventListener('abort', onAbort);
127
- });
128
- },
129
- };
1
+ import { watch } from 'chokidar';
2
+ import { resolve, dirname } from 'path';
3
+ import { mkdir } from 'fs/promises';
4
+ import type { TriggerPlugin, TriggerContext } from '../types';
5
+ import { parseDuration, validatePath } from '../utils';
6
+ import { TriggerTimeoutError } from '../engine';
7
+
8
+ const IS_WINDOWS = process.platform === 'win32';
9
+
10
+ function pathsEqual(a: string, b: string): boolean {
11
+ return IS_WINDOWS ? a.toLowerCase() === b.toLowerCase() : a === b;
12
+ }
13
+
14
+ export const FileTrigger: TriggerPlugin = {
15
+ name: 'file',
16
+ schema: {
17
+ description: 'Wait for a file to appear or be modified before the task runs.',
18
+ fields: {
19
+ path: {
20
+ type: 'path',
21
+ required: true,
22
+ description: 'Path to the file to watch (relative to workDir or absolute).',
23
+ placeholder: 'e.g. build/output.json',
24
+ },
25
+ timeout: {
26
+ type: 'duration',
27
+ description: 'Maximum wait time (e.g. 30s, 5m). Omit or 0 to wait indefinitely.',
28
+ placeholder: '30s',
29
+ },
30
+ },
31
+ },
32
+
33
+ watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
34
+ const filePath = config.path as string;
35
+ if (!filePath) throw new Error(`file trigger: "path" is required`);
36
+
37
+ const safePath = validatePath(filePath, ctx.workDir);
38
+ const timeoutMs = config.timeout != null ? parseDuration(String(config.timeout)) : 0;
39
+
40
+ // Hoist the async work into a named async function so the Promise
41
+ // constructor itself is synchronous — avoids the no-async-promise-executor
42
+ // lint error and ensures exceptions are always propagated via reject().
43
+ async function start(
44
+ resolve_p: (value: unknown) => void,
45
+ reject: (reason?: unknown) => void,
46
+ ): Promise<void> {
47
+ if (ctx.signal.aborted) {
48
+ reject(new Error('Pipeline aborted'));
49
+ return;
50
+ }
51
+
52
+ let settled = false;
53
+ let timer: ReturnType<typeof setTimeout> | null = null;
54
+
55
+ // Ensure the parent directory exists so the watcher doesn't fail
56
+ // with ENOENT for nested paths like `build/output/result.json`.
57
+ const dir = dirname(safePath);
58
+ try {
59
+ await mkdir(dir, { recursive: true });
60
+ } catch { /* best effort — dir may already exist */ }
61
+
62
+ // Pass `cwd: dir` so chokidar resolves paths relative to the watched
63
+ // directory. The 'add'/'change' events will then carry paths relative
64
+ // to `dir`, which we resolve with `resolve(dir, addedPath)` for an
65
+ // accurate absolute comparison fixing the ambiguous process.cwd()
66
+ // resolution of the previous implementation.
67
+ const watcher = watch(dir, {
68
+ ignoreInitial: true,
69
+ depth: 0,
70
+ cwd: dir,
71
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
72
+ });
73
+
74
+ const cleanup = () => {
75
+ if (settled) return;
76
+ settled = true;
77
+ watcher.close().catch(() => { /* ignore */ });
78
+ if (timer) clearTimeout(timer);
79
+ ctx.signal.removeEventListener('abort', onAbort);
80
+ };
81
+
82
+ const onAbort = () => {
83
+ cleanup();
84
+ reject(new Error('Pipeline aborted'));
85
+ };
86
+
87
+ watcher.on('add', (addedPath: string) => {
88
+ if (settled) return;
89
+ if (pathsEqual(resolve(dir, addedPath), safePath)) {
90
+ cleanup();
91
+ resolve_p({ path: safePath });
92
+ }
93
+ });
94
+
95
+ // Also fire on 'change' so that overwriting an existing file is detected.
96
+ // Without this, upstream tasks that truncate-and-rewrite a file emit only
97
+ // a 'change' event and the downstream trigger would never resolve.
98
+ watcher.on('change', (changedPath: string) => {
99
+ if (settled) return;
100
+ if (pathsEqual(resolve(dir, changedPath), safePath)) {
101
+ cleanup();
102
+ resolve_p({ path: safePath });
103
+ }
104
+ });
105
+
106
+ watcher.on('error', (err: unknown) => {
107
+ if (settled) return;
108
+ cleanup();
109
+ reject(new Error(`file trigger watch error: ${err instanceof Error ? err.message : String(err)}`));
110
+ });
111
+
112
+ // After the watcher finishes its initial scan, check if the file already exists.
113
+ // Doing this inside 'ready' eliminates the race window between existence check
114
+ // and watcher startup, so we neither miss events nor double-resolve.
115
+ watcher.on('ready', () => {
116
+ if (settled) return;
117
+ Bun.file(safePath).exists().then((exists) => {
118
+ if (settled) return;
119
+ if (exists) {
120
+ cleanup();
121
+ resolve_p({ path: safePath });
122
+ }
123
+ }).catch((err: unknown) => {
124
+ if (settled) return;
125
+ cleanup();
126
+ reject(new Error(`file trigger existence check failed: ${err instanceof Error ? err.message : String(err)}`));
127
+ });
128
+ });
129
+
130
+ if (timeoutMs > 0) {
131
+ timer = setTimeout(() => {
132
+ if (settled) return;
133
+ cleanup();
134
+ reject(new TriggerTimeoutError(`file trigger timeout: ${filePath} did not appear within ${config.timeout}`));
135
+ }, timeoutMs);
136
+ }
137
+
138
+ ctx.signal.addEventListener('abort', onAbort);
139
+ }
140
+
141
+ return new Promise((resolve_p, reject) => {
142
+ start(resolve_p, reject).catch(reject);
143
+ });
144
+ },
145
+ };
package/src/utils.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { resolve, relative } from 'path';
1
+ import { resolve, relative, parse as parsePath } from 'path';
2
+ import { realpathSync, lstatSync, existsSync } from 'fs';
2
3
  import { randomBytes } from 'crypto';
3
4
 
4
5
  const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
@@ -21,15 +22,65 @@ export function parseDuration(input: string): number {
21
22
 
22
23
  export function validatePath(filePath: string, projectRoot: string): string {
23
24
  const resolved = resolve(projectRoot, filePath);
24
- const rel = relative(projectRoot, resolved);
25
25
 
26
- if (rel.startsWith('..') || rel.startsWith('/') || /^[a-zA-Z]:/.test(rel)) {
26
+ // D2: Cross-drive check (Windows) path.relative('C:\\root', 'D:\\x') returns
27
+ // 'D:\\x' which does NOT start with '..', so a pure relative check would wrongly
28
+ // allow cross-drive paths. Reject them explicitly before any further comparison.
29
+ if (parsePath(projectRoot).root !== parsePath(resolved).root) {
30
+ throw new Error(
31
+ `Security: path "${filePath}" is on a different drive than the project root "${projectRoot}".`
32
+ );
33
+ }
34
+
35
+ const rel = relative(projectRoot, resolved);
36
+ if (rel.startsWith('..') || rel.startsWith('/')) {
27
37
  throw new Error(
28
38
  `Security: path "${filePath}" escapes project root. ` +
29
39
  `All file references must be within "${projectRoot}".`
30
40
  );
31
41
  }
32
42
 
43
+ // D1: Resolve symlinks and re-validate so a symlink whose string path is
44
+ // inside the project root but whose target lies outside is rejected.
45
+ // Only resolve if the path exists on disk; at parse time the file may not
46
+ // yet exist (e.g. a future output path), so we skip realpath for absent paths.
47
+ if (existsSync(resolved)) {
48
+ // Reject the entry outright if it is itself a symlink — callers that want
49
+ // to allow symlinks within the tree can pass pre-resolved paths.
50
+ try {
51
+ const stat = lstatSync(resolved);
52
+ if (stat.isSymbolicLink()) {
53
+ throw new Error(
54
+ `Security: path "${filePath}" is a symbolic link. Symbolic links are not allowed within the project root.`
55
+ );
56
+ }
57
+ } catch (err) {
58
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
59
+ }
60
+
61
+ // Also verify the real (fully resolved) path stays within the project root.
62
+ let real: string;
63
+ try {
64
+ real = realpathSync.native(resolved);
65
+ } catch {
66
+ real = resolved; // path vanished between existsSync and realpathSync — skip
67
+ }
68
+ const realRoot = (() => {
69
+ try { return realpathSync.native(projectRoot); } catch { return projectRoot; }
70
+ })();
71
+ if (parsePath(realRoot).root !== parsePath(real).root) {
72
+ throw new Error(
73
+ `Security: resolved path "${real}" is on a different drive than the project root "${realRoot}".`
74
+ );
75
+ }
76
+ const realRel = relative(realRoot, real);
77
+ if (realRel.startsWith('..') || realRel.startsWith('/')) {
78
+ throw new Error(
79
+ `Security: path "${filePath}" resolves via symlink to "${real}" which escapes project root "${realRoot}".`
80
+ );
81
+ }
82
+ }
83
+
33
84
  return resolved;
34
85
  }
35
86
 
@@ -13,6 +13,15 @@ function isValidDuration(input: string): boolean {
13
13
  return DURATION_RE.test(input.trim());
14
14
  }
15
15
 
16
+ // D8: IDs may only contain letters, digits, underscores, and hyphens, and must
17
+ // start with a letter or underscore. Dots are explicitly forbidden because the
18
+ // engine uses "trackId.taskId" as the qualified separator — a dot in either
19
+ // part creates an ambiguous qualified ID and breaks resolveRef.
20
+ const ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
21
+ function isValidId(id: string): boolean {
22
+ return ID_RE.test(id);
23
+ }
24
+
16
25
  const VALID_ON_FAILURE = new Set(['skip_downstream', 'stop_all', 'ignore']);
17
26
  const VALID_REASONING_EFFORT = new Set(['low', 'medium', 'high']);
18
27
 
@@ -128,6 +137,11 @@ export function validateRaw(
128
137
 
129
138
  if (!track.id?.trim()) {
130
139
  errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
140
+ } else if (!isValidId(track.id)) {
141
+ errors.push({
142
+ path: `${trackPath}.id`,
143
+ message: `Track id "${track.id}" contains invalid characters. IDs must match /^[A-Za-z_][A-Za-z0-9_-]*$/ (no dots, spaces, or special chars).`,
144
+ });
131
145
  } else if (seenTrackIds.has(track.id)) {
132
146
  errors.push({ path: `${trackPath}.id`, message: `Duplicate track id "${track.id}"` });
133
147
  } else {
@@ -175,6 +189,12 @@ export function validateRaw(
175
189
  continue; // Can't check further without an id
176
190
  }
177
191
 
192
+ if (!isValidId(task.id)) {
193
+ errors.push({
194
+ path: `${taskPath}.id`,
195
+ message: `Task id "${task.id}" contains invalid characters. IDs must match /^[A-Za-z_][A-Za-z0-9_-]*$/ (no dots, spaces, or special chars).`,
196
+ });
197
+ }
178
198
  if (seenTaskIds.has(task.id)) {
179
199
  errors.push({ path: taskPath, message: `Duplicate task id "${task.id}" in track "${track.id}"` });
180
200
  }