@dogfood-lab/ingest 1.2.2 → 1.2.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.
@@ -1,36 +1,36 @@
1
- /**
2
- * unsafe-segment.js — central path-segment safety helper.
3
- *
4
- * Three callsites previously defined or duplicated this regex (F-916867-005):
5
- * - packages/ingest/persist.js (canonical instance)
6
- * - packages/ingest/load-context.js (loadRepoPolicy + githubScenarioFetcher)
7
- * - packages/findings/derive/load-records.js (the missing third callsite)
8
- *
9
- * The check rejects path-traversal substrings (`..`) and any path separator
10
- * (`/`, `\`). Single dots remain legal because GitHub permits dotted org/repo
11
- * names like `next.js`, `mcp-tool-shop.github.io`, `repo.io`. The submission
12
- * schema's repo pattern `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` agrees.
13
- *
14
- * F-375053-006 regression — an earlier `/[.\/]/` was over-broad and crashed
15
- * legitimate submissions inside writeRecord. The narrower `/\.\.|[/\\]/` has
16
- * stood since wave 9; this helper is the productized form.
17
- */
18
-
19
- /**
20
- * Regex matching unsafe substrings in a single path segment.
21
- * Use `.test(segment)` — returns true if the segment is unsafe.
22
- *
23
- * @type {RegExp}
24
- */
25
- export const UNSAFE_SEGMENT = /\.\.|[/\\]/;
26
-
27
- /**
28
- * Predicate form: returns true when the given segment contains a path-traversal
29
- * substring or a path separator.
30
- *
31
- * @param {string} segment - A single path-segment candidate (e.g. an org or repo name).
32
- * @returns {boolean}
33
- */
34
- export function isUnsafeSegment(segment) {
35
- return UNSAFE_SEGMENT.test(segment);
36
- }
1
+ /**
2
+ * unsafe-segment.js — central path-segment safety helper.
3
+ *
4
+ * Three callsites previously defined or duplicated this regex (F-916867-005):
5
+ * - packages/ingest/persist.js (canonical instance)
6
+ * - packages/ingest/load-context.js (loadRepoPolicy + githubScenarioFetcher)
7
+ * - packages/findings/derive/load-records.js (the missing third callsite)
8
+ *
9
+ * The check rejects path-traversal substrings (`..`) and any path separator
10
+ * (`/`, `\`). Single dots remain legal because GitHub permits dotted org/repo
11
+ * names like `next.js`, `mcp-tool-shop.github.io`, `repo.io`. The submission
12
+ * schema's repo pattern `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` agrees.
13
+ *
14
+ * F-375053-006 regression — an earlier `/[.\/]/` was over-broad and crashed
15
+ * legitimate submissions inside writeRecord. The narrower `/\.\.|[/\\]/` has
16
+ * stood since wave 9; this helper is the productized form.
17
+ */
18
+
19
+ /**
20
+ * Regex matching unsafe substrings in a single path segment.
21
+ * Use `.test(segment)` — returns true if the segment is unsafe.
22
+ *
23
+ * @type {RegExp}
24
+ */
25
+ export const UNSAFE_SEGMENT = /\.\.|[/\\]/;
26
+
27
+ /**
28
+ * Predicate form: returns true when the given segment contains a path-traversal
29
+ * substring or a path separator.
30
+ *
31
+ * @param {string} segment - A single path-segment candidate (e.g. an org or repo name).
32
+ * @returns {boolean}
33
+ */
34
+ export function isUnsafeSegment(segment) {
35
+ return UNSAFE_SEGMENT.test(segment);
36
+ }
package/load-context.js CHANGED
@@ -1,131 +1,161 @@
1
- /**
2
- * Context loader
3
- *
4
- * Gathers everything the verifier needs:
5
- * - Global policy
6
- * - Repo policy (optional, missing is valid)
7
- * - Scenario definitions from source repo (optional, missing becomes rejection reason)
8
- * - Payload normalization
9
- *
10
- * Scenario loading uses a fetch adapter so it can be stubbed in tests.
11
- */
12
-
13
- import { readFileSync, existsSync } from 'node:fs';
14
- import { join } from 'node:path';
15
- import yaml from 'js-yaml';
16
-
17
- import { isUnsafeSegment } from './lib/unsafe-segment.js';
18
-
19
- /**
20
- * Load the global policy.
21
- *
22
- * @param {string} repoRoot
23
- * @returns {object}
24
- */
25
- export function loadGlobalPolicy(repoRoot) {
26
- const path = join(repoRoot, 'policies', 'global-policy.yaml');
27
- return yaml.load(readFileSync(path, 'utf-8'));
28
- }
29
-
30
- /**
31
- * Load repo-specific policy. Returns null if no policy exists.
32
- *
33
- * @param {string} repoSlug - e.g. "mcp-tool-shop-org/dogfood-labs"
34
- * @param {string} repoRoot
35
- * @returns {object|null}
36
- */
37
- export function loadRepoPolicy(repoSlug, repoRoot) {
38
- const [org, repo] = repoSlug.split('/');
39
- if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) return null;
40
- const path = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
41
-
42
- if (!existsSync(path)) return null;
43
- try {
44
- return yaml.load(readFileSync(path, 'utf-8'));
45
- } catch {
46
- console.warn(`load-context: malformed YAML in repo policy for ${repoSlug}`);
47
- return null;
48
- }
49
- }
50
-
51
- /**
52
- * Default scenario fetcher that reads from the local filesystem.
53
- * Used when dogfood-labs is dogfooding itself.
54
- *
55
- * @param {string} repoRoot - Root of the source repo
56
- * @returns {object} Scenario fetch adapter
57
- */
58
- export function localScenarioFetcher(repoRoot) {
59
- return {
60
- async fetch(scenarioId) {
61
- if (!/^[\w-]+$/.test(scenarioId)) return null;
62
- const path = join(repoRoot, 'dogfood', 'scenarios', `${scenarioId}.yaml`);
63
- if (!existsSync(path)) return null;
64
- return yaml.load(readFileSync(path, 'utf-8'));
65
- }
66
- };
67
- }
68
-
69
- /**
70
- * GitHub scenario fetcher. Loads scenario definitions from a source repo
71
- * via the GitHub API at a specific commit SHA.
72
- *
73
- * @param {string} token - GitHub PAT
74
- * @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
75
- * @param {string} commitSha - Commit to fetch scenarios from
76
- * @returns {object} Scenario fetch adapter
77
- */
78
- export function githubScenarioFetcher(token, repoSlug, commitSha) {
79
- const [org, repo] = repoSlug.split('/');
80
- if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) {
81
- return { async fetch() { return null; } };
82
- }
83
- return {
84
- async fetch(scenarioId) {
85
- if (!/^[\w-]+$/.test(scenarioId)) return null;
86
- const path = `dogfood/scenarios/${scenarioId}.yaml`;
87
- const url = `https://api.github.com/repos/${repoSlug}/contents/${path}?ref=${commitSha}`;
88
-
89
- try {
90
- const resp = await globalThis.fetch(url, {
91
- headers: {
92
- Authorization: `Bearer ${token}`,
93
- Accept: 'application/vnd.github.raw+json',
94
- 'X-GitHub-Api-Version': '2022-11-28'
95
- }
96
- });
97
- if (!resp.ok) return null;
98
- const text = await resp.text();
99
- return yaml.load(text);
100
- } catch {
101
- return null;
102
- }
103
- }
104
- };
105
- }
106
-
107
- /**
108
- * Load all scenario definitions referenced by a submission's scenario_results.
109
- *
110
- * @param {object} submission
111
- * @param {object} scenarioFetcher - { fetch(scenarioId) => Promise<object|null> }
112
- * @returns {Promise<{ scenarios: Map<string, object>, errors: string[] }>}
113
- */
114
- export async function loadScenarios(submission, scenarioFetcher) {
115
- const scenarios = new Map();
116
- const errors = [];
117
-
118
- for (const sr of submission.scenario_results || []) {
119
- const id = sr.scenario_id;
120
- if (scenarios.has(id)) continue;
121
-
122
- const definition = await scenarioFetcher.fetch(id);
123
- if (definition) {
124
- scenarios.set(id, definition);
125
- } else {
126
- errors.push(`scenario "${id}" could not be loaded from source repo`);
127
- }
128
- }
129
-
130
- return { scenarios, errors };
131
- }
1
+ /**
2
+ * Context loader
3
+ *
4
+ * Gathers everything the verifier needs:
5
+ * - Global policy
6
+ * - Repo policy (optional, missing is valid)
7
+ * - Scenario definitions from source repo (optional, missing becomes rejection reason)
8
+ * - Payload normalization
9
+ *
10
+ * Scenario loading uses a fetch adapter so it can be stubbed in tests.
11
+ */
12
+
13
+ import { readFileSync, existsSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import yaml from 'js-yaml';
16
+
17
+ import { isUnsafeSegment } from './lib/unsafe-segment.js';
18
+
19
+ /**
20
+ * Load the global policy.
21
+ *
22
+ * The global policy is REQUIRED — unlike `loadRepoPolicy` which silently
23
+ * returns null when a repo-specific override is absent, a missing or malformed
24
+ * global policy throws with a structured, operator-actionable message naming
25
+ * the resolved path and the failure mode (missing vs unreadable vs invalid
26
+ * YAML, with line/column from `yaml.YAMLException.mark` when available).
27
+ * Receiver workflows would otherwise crash with a raw `ENOENT` or
28
+ * `YAMLException` stack trace, leaving the operator to guess which file
29
+ * to fix.
30
+ *
31
+ * @param {string} repoRoot
32
+ * @returns {object}
33
+ */
34
+ export function loadGlobalPolicy(repoRoot) {
35
+ const path = join(repoRoot, 'policies', 'global-policy.yaml');
36
+ let raw;
37
+ try {
38
+ raw = readFileSync(path, 'utf-8');
39
+ } catch (e) {
40
+ if (e.code === 'ENOENT') {
41
+ throw new Error(
42
+ `Global policy missing: ${path}\n` +
43
+ `The ingest pipeline requires a global policy file. ` +
44
+ `Create it from policies/global-policy.example.yaml or the project README.`
45
+ );
46
+ }
47
+ throw new Error(`Global policy unreadable: ${path} — ${e.message}`);
48
+ }
49
+ try {
50
+ return yaml.load(raw);
51
+ } catch (e) {
52
+ const where = e.mark ? ` at line ${e.mark.line + 1}, column ${e.mark.column + 1}` : '';
53
+ throw new Error(
54
+ `Global policy YAML invalid: ${path}${where} — ${e.message}\n` +
55
+ `Fix the YAML and re-run.`
56
+ );
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Load repo-specific policy. Returns null if no policy exists.
62
+ *
63
+ * @param {string} repoSlug - e.g. "mcp-tool-shop-org/dogfood-labs"
64
+ * @param {string} repoRoot
65
+ * @returns {object|null}
66
+ */
67
+ export function loadRepoPolicy(repoSlug, repoRoot) {
68
+ const [org, repo] = repoSlug.split('/');
69
+ if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) return null;
70
+ const path = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
71
+
72
+ if (!existsSync(path)) return null;
73
+ try {
74
+ return yaml.load(readFileSync(path, 'utf-8'));
75
+ } catch {
76
+ console.warn(`load-context: malformed YAML in repo policy for ${repoSlug}`);
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Default scenario fetcher that reads from the local filesystem.
83
+ * Used when dogfood-labs is dogfooding itself.
84
+ *
85
+ * @param {string} repoRoot - Root of the source repo
86
+ * @returns {object} Scenario fetch adapter
87
+ */
88
+ export function localScenarioFetcher(repoRoot) {
89
+ return {
90
+ async fetch(scenarioId) {
91
+ if (!/^[\w-]+$/.test(scenarioId)) return null;
92
+ const path = join(repoRoot, 'dogfood', 'scenarios', `${scenarioId}.yaml`);
93
+ if (!existsSync(path)) return null;
94
+ return yaml.load(readFileSync(path, 'utf-8'));
95
+ }
96
+ };
97
+ }
98
+
99
+ /**
100
+ * GitHub scenario fetcher. Loads scenario definitions from a source repo
101
+ * via the GitHub API at a specific commit SHA.
102
+ *
103
+ * @param {string} token - GitHub PAT
104
+ * @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
105
+ * @param {string} commitSha - Commit to fetch scenarios from
106
+ * @returns {object} Scenario fetch adapter
107
+ */
108
+ export function githubScenarioFetcher(token, repoSlug, commitSha) {
109
+ const [org, repo] = repoSlug.split('/');
110
+ if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) {
111
+ return { async fetch() { return null; } };
112
+ }
113
+ return {
114
+ async fetch(scenarioId) {
115
+ if (!/^[\w-]+$/.test(scenarioId)) return null;
116
+ const path = `dogfood/scenarios/${scenarioId}.yaml`;
117
+ const url = `https://api.github.com/repos/${repoSlug}/contents/${path}?ref=${commitSha}`;
118
+
119
+ try {
120
+ const resp = await globalThis.fetch(url, {
121
+ headers: {
122
+ Authorization: `Bearer ${token}`,
123
+ Accept: 'application/vnd.github.raw+json',
124
+ 'X-GitHub-Api-Version': '2022-11-28'
125
+ }
126
+ });
127
+ if (!resp.ok) return null;
128
+ const text = await resp.text();
129
+ return yaml.load(text);
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Load all scenario definitions referenced by a submission's scenario_results.
139
+ *
140
+ * @param {object} submission
141
+ * @param {object} scenarioFetcher - { fetch(scenarioId) => Promise<object|null> }
142
+ * @returns {Promise<{ scenarios: Map<string, object>, errors: string[] }>}
143
+ */
144
+ export async function loadScenarios(submission, scenarioFetcher) {
145
+ const scenarios = new Map();
146
+ const errors = [];
147
+
148
+ for (const sr of submission.scenario_results || []) {
149
+ const id = sr.scenario_id;
150
+ if (scenarios.has(id)) continue;
151
+
152
+ const definition = await scenarioFetcher.fetch(id);
153
+ if (definition) {
154
+ scenarios.set(id, definition);
155
+ } else {
156
+ errors.push(`scenario "${id}" could not be loaded from source repo`);
157
+ }
158
+ }
159
+
160
+ return { scenarios, errors };
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/ingest",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "type": "module",
5
5
  "description": "Ingestion pipeline for testing-os. Thin glue: dispatch → verifier → persist → indexes.",
6
6
  "main": "run.js",
@@ -34,7 +34,7 @@
34
34
  "js-yaml": "^4.1.0"
35
35
  },
36
36
  "engines": {
37
- "node": ">=20"
37
+ "node": ">=22"
38
38
  },
39
39
  "author": "mcp-tool-shop",
40
40
  "license": "MIT",