@dogfood-lab/ingest 1.2.1 → 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.
- package/lib/unsafe-segment.js +36 -36
- package/load-context.js +161 -131
- package/package.json +2 -2
- package/persist.js +172 -172
- package/rebuild-indexes.js +356 -356
- package/run.js +560 -540
- package/validate-record.js +83 -83
package/lib/unsafe-segment.js
CHANGED
|
@@ -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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
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": ">=
|
|
37
|
+
"node": ">=22"
|
|
38
38
|
},
|
|
39
39
|
"author": "mcp-tool-shop",
|
|
40
40
|
"license": "MIT",
|