topo-workbench 0.0.1 → 1.0.1
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/README.md +28 -2
- package/bin/topo-workbench.js +4 -1
- package/lib/cli.js +163 -0
- package/lib/providers.js +37 -0
- package/lib/scrub.js +118 -0
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# topo-workbench
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run [Workbench](https://modeltopography.com), the Model Topography benchmark, on any model you can reach, then submit
|
|
4
|
+
the run for grading. Workbench is a [Harbor](https://harborframework.com) dataset, `model-topography/workbench@1.0`: 12
|
|
5
|
+
tasks across Entry, Middle Management and Senior Executive work.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
Install it, check your setup, run, and submit:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --global topo-workbench
|
|
11
|
+
topo-workbench doctor
|
|
12
|
+
OPENROUTER_API_KEY=... topo-workbench run --agent opencode --model openrouter/moonshotai/kimi-k3
|
|
13
|
+
topo-workbench submit workbench-jobs/2026-10-23__12-00-00
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **Any agent Harbor runs:** `claude-code`, `codex`, `gemini-cli`, `kimi-cli`, `grok-build`, `opencode`, `mini-swe-agent`,
|
|
17
|
+
`openhands`, `aider`, `goose` and more.
|
|
18
|
+
- **Any model:** name its provider first: `openrouter/...`, `deepseek/...`, `gemini/...`, `xai/...`, `anthropic/...`. The run
|
|
19
|
+
may reach that provider's API and nothing else. Name another endpoint with `--allow-host`.
|
|
20
|
+
- **Your key stays yours.** It is read from your environment, used by your run, and never uploaded. `submit` refuses to
|
|
21
|
+
upload a job that holds a key or a residual personal identifier. Before submitting, create an owner-only
|
|
22
|
+
`~/.config/mtb-export/identifiers` file with one personal identifier per line. `submit` scrubs that list, Codex
|
|
23
|
+
creator IDs, and your home directory from a staged copy. It rejects residual text, binary identifiers, and unsafe
|
|
24
|
+
symbolic links. Use `--identifiers FILE` to use another owner-only file.
|
|
25
|
+
- **Community full sample.** The default is 10 attempts at each task. Use `--task` or `--tier` to try a few first.
|
|
26
|
+
- **Published point eligibility.** A point needs at least five scored attempts at every size. It reports its actual
|
|
27
|
+
attempt counts. Partial and top-up campaigns can pool when they use the same task conditions.
|
|
28
|
+
- **Grading is sealed.** Your run does not grade itself. The maintainers grade the uploaded trials with the private
|
|
29
|
+
grader, and the result joins the map once its campaign is published.
|
|
30
|
+
|
|
31
|
+
Requirements: Docker, Node 22 or newer, and Harbor 0.23.0 (`uv tool install harbor==0.23.0`).
|
package/bin/topo-workbench.js
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import { main } from '../lib/cli.js';
|
|
3
|
+
// Every error ends here as one line and a failing exit code, never a stack trace.
|
|
4
|
+
try { process.exitCode = await main(); }
|
|
5
|
+
catch (error) { console.error(`topo-workbench: ${error.message}`); process.exitCode = 1; }
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// topo-workbench: run Workbench on any model with Harbor, then submit the run for grading. The grader is sealed
|
|
2
|
+
// (Dan, 2026-10-11, 2A), so a run here never grades itself: Harbor runs with verification off, and the maintainers
|
|
3
|
+
// regrade the uploaded trials with the private grader before a point reaches the map.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
import { hostsFor, providerOf, PROVIDERS } from './providers.js';
|
|
10
|
+
import { findPersonalData, findSecrets, readIdentifiers, scrubPersonalData } from './scrub.js';
|
|
11
|
+
|
|
12
|
+
export const HARBOR_VERSION = '0.23.0';
|
|
13
|
+
export const DATASET = 'model-topography/workbench@1.0';
|
|
14
|
+
export const ORG = 'model-topography';
|
|
15
|
+
export const REPOSITORY = 'ddmirolli/workbench';
|
|
16
|
+
// Community runs default to the 10-attempt full sample. A published point needs five scored attempts at every size.
|
|
17
|
+
export const FULL_SAMPLE = 10;
|
|
18
|
+
|
|
19
|
+
const USAGE = `topo-workbench: run Workbench on any model, then submit the run for grading.
|
|
20
|
+
|
|
21
|
+
topo-workbench doctor
|
|
22
|
+
topo-workbench run --agent AGENT --model PROVIDER/MODEL [--reasoning LEVEL] [--attempts 10] [--concurrent 4]
|
|
23
|
+
[--tier entry|management|executive ...] [--task workbench-management-4 ...] [--allow-host HOST ...] [--out DIR] [-- HARBOR_ARGS]
|
|
24
|
+
topo-workbench submit JOB_DIRECTORY [--dry-run] [--identifiers FILE]
|
|
25
|
+
|
|
26
|
+
AGENT is any Harbor agent: claude-code, codex, gemini-cli, kimi-cli, opencode, mini-swe-agent, openhands, aider, goose...
|
|
27
|
+
Your own provider key is read from your environment (for example OPENROUTER_API_KEY) and never uploaded.
|
|
28
|
+
Docs: https://modeltopography.com/participate`;
|
|
29
|
+
|
|
30
|
+
const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });
|
|
31
|
+
|
|
32
|
+
export function harborVersion(harbor = 'harbor') {
|
|
33
|
+
const out = run(harbor, ['--version']);
|
|
34
|
+
return out.status === 0 ? out.stdout.trim().split(/\s+/).pop() : null;
|
|
35
|
+
}
|
|
36
|
+
export function doctor({ harbor = 'harbor', log = console.log } = {}) {
|
|
37
|
+
const problems = [];
|
|
38
|
+
const docker = run('docker', ['info', '--format', '{{.ServerVersion}}']);
|
|
39
|
+
if (docker.status !== 0) problems.push('Docker is not running. Start Docker Desktop or the Docker daemon.');
|
|
40
|
+
const version = harborVersion(harbor);
|
|
41
|
+
if (!version) problems.push(`Harbor is not installed. Install it: uv tool install harbor==${HARBOR_VERSION}`);
|
|
42
|
+
else if (version !== HARBOR_VERSION) problems.push(`Harbor ${version} is installed; Workbench is measured with ${HARBOR_VERSION}: uv tool install --force harbor==${HARBOR_VERSION}`);
|
|
43
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
44
|
+
if (major < 22) problems.push(`Node ${process.versions.node}; Workbench needs Node 22 or newer.`);
|
|
45
|
+
for (const problem of problems) log(`- ${problem}`);
|
|
46
|
+
if (!problems.length) log(`Ready: Docker, Harbor ${HARBOR_VERSION}, Node ${process.versions.node}.`);
|
|
47
|
+
return problems;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// How each agent takes a reasoning level. Agents not listed take theirs through `-- --ak key=value`.
|
|
51
|
+
export function reasoningArgs(agent, level) {
|
|
52
|
+
if (!level) return [];
|
|
53
|
+
if (agent === 'codex') return ['--ak', `config=${JSON.stringify({ model_reasoning_effort: level })}`];
|
|
54
|
+
if (agent === 'claude-code') return ['--ak', `reasoning_effort=${level}`];
|
|
55
|
+
throw new Error(`Pass ${agent}'s reasoning setting to Harbor directly: -- --ak KEY=VALUE`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The Harbor command for a run. Verification is off: the grader is sealed.
|
|
59
|
+
export function runArgs(options) {
|
|
60
|
+
const { agent, model, reasoning, attempts = FULL_SAMPLE, concurrent = 4, tiers = [], tasks = [], allowHosts = [], out, dataset = DATASET, path: local, extra = [] } = options;
|
|
61
|
+
if (!agent) throw new Error('Name the agent: --agent claude-code, codex, opencode, ...');
|
|
62
|
+
if (!model) throw new Error('Name the model: --model PROVIDER/MODEL, such as openrouter/moonshotai/kimi-k2');
|
|
63
|
+
const args = ['run', ...(local ? ['-p', local] : ['-d', dataset]), '-a', agent, '-m', model, '-k', String(attempts), '-n', String(concurrent),
|
|
64
|
+
'--disable-verification', '-o', out,
|
|
65
|
+
// The runtime image's npm cache belongs to root; agents that install with npm need a cache they can write.
|
|
66
|
+
'--ae', 'NPM_CONFIG_CACHE=/tmp/npm-cache'];
|
|
67
|
+
for (const host of hostsFor(agent, model, allowHosts)) args.push('--allow-agent-host', host);
|
|
68
|
+
for (const tier of tiers) args.push('-i', `*workbench-${tier}-*`);
|
|
69
|
+
for (const task of tasks) args.push('-i', `*${task}`);
|
|
70
|
+
args.push(...reasoningArgs(agent, reasoning), ...extra);
|
|
71
|
+
return args;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// What a submission says about its run, read from Harbor's own job record.
|
|
75
|
+
export function describeJob(job) {
|
|
76
|
+
const config = JSON.parse(fs.readFileSync(path.join(job, 'config.json'), 'utf8'));
|
|
77
|
+
const trials = fs.readdirSync(job, { withFileTypes: true }).filter(e => e.isDirectory() && fs.existsSync(path.join(job, e.name, 'result.json'))).map(e => e.name);
|
|
78
|
+
const agent = config.agents?.[0] ?? config.agent ?? {};
|
|
79
|
+
return { agent: agent.name ?? null, model: agent.model_name ?? null, kwargs: agent.kwargs ?? {}, trials: trials.length,
|
|
80
|
+
tasks: [...new Set(trials.map(t => t.split('__')[0]))].sort() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A scrubbed copy of the job. It removes creator IDs, listed personal identifiers, and the home directory. The
|
|
84
|
+
// original stays untouched. The staged copy must also pass the key, residual-data, and symbolic-link checks.
|
|
85
|
+
export function stageJob(job, staging, { home = os.homedir(), secrets = [], identifiers = [] } = {}) {
|
|
86
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
87
|
+
fs.cpSync(job, staging, { recursive: true, verbatimSymlinks: true });
|
|
88
|
+
const walk = directory => { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
89
|
+
const file = path.join(directory, entry.name);
|
|
90
|
+
if (entry.isDirectory()) walk(file);
|
|
91
|
+
else if (entry.isFile()) {
|
|
92
|
+
const bytes = fs.readFileSync(file);
|
|
93
|
+
if (bytes.includes(0)) continue;
|
|
94
|
+
const text = bytes.toString('utf8'), clean = scrubPersonalData(text, { identifiers, home });
|
|
95
|
+
if (clean !== text) fs.writeFileSync(file, clean);
|
|
96
|
+
}
|
|
97
|
+
} };
|
|
98
|
+
walk(staging);
|
|
99
|
+
// Harbor names an uploaded job by its config's job_name, or by the upload time when there is none, and a download
|
|
100
|
+
// expects that name as the archive's folder: the copy names itself after its folder.
|
|
101
|
+
const config = path.join(staging, 'config.json');
|
|
102
|
+
if (fs.existsSync(config)) { const value = JSON.parse(fs.readFileSync(config, 'utf8')); if (!value.job_name) fs.writeFileSync(config, JSON.stringify({ ...value, job_name: path.basename(staging) }, null, 2)); }
|
|
103
|
+
const stagingName = path.basename(staging);
|
|
104
|
+
return [...findSecrets(staging, { secrets, stagingName }), ...findPersonalData(staging, { identifiers, home, stagingName })];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function main(argv = process.argv.slice(2), { harbor = 'harbor', log = console.log, env = process.env } = {}) {
|
|
108
|
+
const [command, ...rest] = argv;
|
|
109
|
+
const split = rest.indexOf('--'), own = split < 0 ? rest : rest.slice(0, split), extra = split < 0 ? [] : rest.slice(split + 1);
|
|
110
|
+
if (command === 'doctor') return doctor({ harbor, log }).length ? 1 : 0;
|
|
111
|
+
if (command === 'run') {
|
|
112
|
+
const { values } = parseArgs({ args: own, options: { agent: { type: 'string' }, model: { type: 'string' }, reasoning: { type: 'string' },
|
|
113
|
+
attempts: { type: 'string' }, concurrent: { type: 'string' }, tier: { type: 'string', multiple: true }, task: { type: 'string', multiple: true }, 'allow-host': { type: 'string', multiple: true },
|
|
114
|
+
out: { type: 'string' }, dataset: { type: 'string' }, path: { type: 'string' } } });
|
|
115
|
+
if (doctor({ harbor, log: () => {} }).length) { doctor({ harbor, log }); return 1; }
|
|
116
|
+
const provider = providerOf(values.agent, values.model);
|
|
117
|
+
if (provider && PROVIDERS[provider].key && !env[PROVIDERS[provider].key] && !['claude-code', 'codex'].includes(values.agent))
|
|
118
|
+
log(`Note: ${PROVIDERS[provider].key} is not set; the agent may not reach ${provider}.`);
|
|
119
|
+
const out = path.resolve(values.out ?? 'workbench-jobs');
|
|
120
|
+
const args = runArgs({ agent: values.agent, model: values.model, reasoning: values.reasoning, attempts: Number(values.attempts ?? FULL_SAMPLE),
|
|
121
|
+
concurrent: Number(values.concurrent ?? 4), tiers: values.tier ?? [], tasks: values.task ?? [], allowHosts: values['allow-host'] ?? [], out, dataset: values.dataset, path: values.path, extra });
|
|
122
|
+
log(`harbor ${args.join(' ')}`);
|
|
123
|
+
const result = spawnSync(harbor, args, { stdio: 'inherit', env });
|
|
124
|
+
if (result.status === 0) {
|
|
125
|
+
const job = fs.existsSync(out) ? fs.readdirSync(out).filter(n => fs.existsSync(path.join(out, n, 'config.json'))).sort().at(-1) : null;
|
|
126
|
+
log(`\nDone. The default community sample is ${FULL_SAMPLE} attempts at each task. A published point needs at least five scored attempts at every size; compatible partial and top-up campaigns can pool. Submit it for grading: topo-workbench submit ${job ? path.join(out, job) : out + '/<job>'}`);
|
|
127
|
+
}
|
|
128
|
+
return result.status ?? 1;
|
|
129
|
+
}
|
|
130
|
+
if (command === 'submit') {
|
|
131
|
+
const { values, positionals } = parseArgs({ args: own, allowPositionals: true, strict: true,
|
|
132
|
+
options: { 'dry-run': { type: 'boolean' }, identifiers: { type: 'string' } } });
|
|
133
|
+
const dryRun = values['dry-run'] ?? false, job = positionals.length === 1 && path.resolve(positionals[0]);
|
|
134
|
+
if (!job || !fs.existsSync(path.join(job, 'config.json'))) { log('Name a Harbor job directory: topo-workbench submit workbench-jobs/2026-10-23__12-00-00'); return 1; }
|
|
135
|
+
let identifiers;
|
|
136
|
+
try { identifiers = readIdentifiers(values.identifiers); }
|
|
137
|
+
catch (error) { log(`Not uploaded: ${error.message}`); return 1; }
|
|
138
|
+
const secrets = Object.values(PROVIDERS).map(p => env[p.key]).filter(Boolean);
|
|
139
|
+
// The copy keeps the job's own folder name: Harbor names the uploaded job by it, and a download expects it.
|
|
140
|
+
const staging = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'topo-workbench-')), path.basename(job));
|
|
141
|
+
const findings = stageJob(job, staging, { secrets, identifiers });
|
|
142
|
+
if (findings.length) {
|
|
143
|
+
log('Not uploaded: the job holds what must stay on this machine.');
|
|
144
|
+
for (const f of findings) log(`- ${f.file}: ${f.kind}`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
const described = describeJob(staging);
|
|
148
|
+
if (dryRun) { log(`Checked ${described.trials} trials (${described.agent}, ${described.model}): nothing that must stay on this machine. Staged at ${staging}.\nWithout --dry-run: harbor upload ${staging} --private --share-org ${ORG} -y, then a prefilled GitHub issue.`); return 0; }
|
|
149
|
+
log(`Uploading ${described.trials} trials (${described.agent}, ${described.model}) to Harbor Hub, shared with ${ORG}...`);
|
|
150
|
+
const upload = spawnSync(harbor, ['upload', staging, '--private', '--share-org', ORG, '-y'], { encoding: 'utf8', env });
|
|
151
|
+
process.stdout.write(upload.stdout ?? ''); process.stderr.write(upload.stderr ?? '');
|
|
152
|
+
if (upload.status !== 0) return upload.status ?? 1;
|
|
153
|
+
const hub = /(https:\/\/\S*hub\.harborframework\.com\S*)/.exec(upload.stdout ?? '')?.[1] ?? '(see the upload output above)';
|
|
154
|
+
const title = `Submission: ${described.model} with ${described.agent}`;
|
|
155
|
+
const body = [`Harbor job: ${hub}`, `Agent: ${described.agent}`, `Model: ${described.model}`, `Agent options: ${JSON.stringify(described.kwargs)}`,
|
|
156
|
+
`Trials: ${described.trials} across ${described.tasks.join(', ')}`, `topo-workbench: ${JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version}`].join('\n');
|
|
157
|
+
const url = `https://github.com/${REPOSITORY}/issues/new?labels=submission&title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
|
|
158
|
+
log(`\nUploaded. File the submission (opens a prefilled GitHub issue):\n${url}`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
log(USAGE);
|
|
162
|
+
return command === undefined || command === 'help' || command === '--help' ? 0 : 1;
|
|
163
|
+
}
|
package/lib/providers.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The model provider's API hosts for a run. A run's agent may reach its own provider and nothing else beyond the
|
|
2
|
+
// task's allowlist (Dan, 2026-10-11: outsiders run Workbench on any model they can access). Harbor opens each host
|
|
3
|
+
// for the agent's run only (`--allow-agent-host`), so the task itself never changes.
|
|
4
|
+
//
|
|
5
|
+
// A model id names its provider first, as Harbor's agents take it: `openrouter/moonshotai/kimi-k2`,
|
|
6
|
+
// `gemini/gemini-3-pro`, `anthropic/claude-sonnet-5-5`. Add a provider by adding a line.
|
|
7
|
+
export const PROVIDERS = {
|
|
8
|
+
anthropic: { hosts: ['api.anthropic.com'], key: 'ANTHROPIC_API_KEY' },
|
|
9
|
+
openai: { hosts: ['api.openai.com'], key: 'OPENAI_API_KEY' },
|
|
10
|
+
openrouter: { hosts: ['openrouter.ai'], key: 'OPENROUTER_API_KEY' },
|
|
11
|
+
google: { hosts: ['generativelanguage.googleapis.com'], key: 'GEMINI_API_KEY' },
|
|
12
|
+
gemini: { hosts: ['generativelanguage.googleapis.com'], key: 'GEMINI_API_KEY' },
|
|
13
|
+
moonshot: { hosts: ['api.moonshot.ai'], key: 'MOONSHOT_API_KEY' },
|
|
14
|
+
kimi: { hosts: ['api.moonshot.ai'], key: 'MOONSHOT_API_KEY' },
|
|
15
|
+
deepseek: { hosts: ['api.deepseek.com'], key: 'DEEPSEEK_API_KEY' },
|
|
16
|
+
xai: { hosts: ['api.x.ai'], key: 'XAI_API_KEY' },
|
|
17
|
+
meta: { hosts: ['api.llama.com'], key: 'LLAMA_API_KEY' },
|
|
18
|
+
llama: { hosts: ['api.llama.com'], key: 'LLAMA_API_KEY' },
|
|
19
|
+
together: { hosts: ['api.together.xyz'], key: 'TOGETHER_API_KEY' },
|
|
20
|
+
groq: { hosts: ['api.groq.com'], key: 'GROQ_API_KEY' },
|
|
21
|
+
mistral: { hosts: ['api.mistral.ai'], key: 'MISTRAL_API_KEY' },
|
|
22
|
+
fireworks: { hosts: ['api.fireworks.ai'], key: 'FIREWORKS_API_KEY' },
|
|
23
|
+
};
|
|
24
|
+
// Agents that sign in to their own service rather than take a provider prefix.
|
|
25
|
+
const AGENT_PROVIDER = { 'claude-code': 'anthropic', codex: 'openai', 'gemini-cli': 'google', 'kimi-cli': 'moonshot', 'grok-build': 'xai' };
|
|
26
|
+
|
|
27
|
+
export function providerOf(agent, model) {
|
|
28
|
+
const prefix = String(model ?? '').split('/')[0].toLowerCase();
|
|
29
|
+
if (PROVIDERS[prefix]) return prefix;
|
|
30
|
+
return AGENT_PROVIDER[agent] ?? null;
|
|
31
|
+
}
|
|
32
|
+
// The hosts a run opens: its provider's, then any the runner names with --allow-host, each once.
|
|
33
|
+
export function hostsFor(agent, model, extra = []) {
|
|
34
|
+
const provider = providerOf(agent, model);
|
|
35
|
+
if (!provider && !extra.length) throw new Error(`No provider is known for model "${model}" with agent "${agent}". Name its API host: --allow-host api.example.com`);
|
|
36
|
+
return [...new Set([...(provider ? PROVIDERS[provider].hosts : []), ...extra])];
|
|
37
|
+
}
|
package/lib/scrub.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// What must never leave a runner's machine in an upload: keys, creator IDs, listed personal identifiers, and the
|
|
2
|
+
// runner's home directory. The check reads every file of a job, text or binary, and names files and kinds, never
|
|
3
|
+
// values. The caller uploads only a staged copy that passes these checks.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const KEY_PATTERNS = [
|
|
9
|
+
['Anthropic key', /sk-ant-[A-Za-z0-9_-]{20,}/],
|
|
10
|
+
['OpenRouter key', /sk-or-[A-Za-z0-9_-]{20,}/],
|
|
11
|
+
['OpenAI key', /sk-(?!or-|ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{32,}/],
|
|
12
|
+
['Google key', /AIza[0-9A-Za-z_-]{35}/],
|
|
13
|
+
['xAI key', /xai-[A-Za-z0-9]{40,}/],
|
|
14
|
+
['Groq key', /gsk_[A-Za-z0-9]{40,}/],
|
|
15
|
+
['Hugging Face token', /hf_[A-Za-z0-9]{30,}/],
|
|
16
|
+
['GitHub token', /gh[pousr]_[A-Za-z0-9]{36,}/],
|
|
17
|
+
['AWS access key', /AKIA[0-9A-Z]{16}/],
|
|
18
|
+
['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const REDACTED = '[redacted]';
|
|
22
|
+
export const DEFAULT_IDENTIFIERS = path.join(os.homedir(), '.config/mtb-export/identifiers');
|
|
23
|
+
const CREATOR = /"(creator_user_id|creator_account_id)"\s*:\s*("(?:[^"\\]|\\.)*"|null|true|false|-?[0-9][0-9.eE+-]*)/g;
|
|
24
|
+
const CREATOR_ESCAPED_START = /(\\+)"(creator_user_id|creator_account_id)\1"\s*:\s*(?:(\1")|(null|true|false|-?[0-9][0-9.eE+-]*))/g;
|
|
25
|
+
const keptCreatorValue = value => value === `"${REDACTED}"` || value === 'null' || /^(\\+)"\[redacted\]\1"$/.test(value);
|
|
26
|
+
|
|
27
|
+
function escapedCreators(text) {
|
|
28
|
+
const matches = [], start = new RegExp(CREATOR_ESCAPED_START);
|
|
29
|
+
for (let match; (match = start.exec(text));) {
|
|
30
|
+
const [source, escapes, key, stringStart, bare] = match;
|
|
31
|
+
if (bare) { matches.push({ start: match.index, end: match.index + source.length, key, value: bare, escapes }); continue; }
|
|
32
|
+
const depth = escapes.length, closeStride = 2 * (depth + 1), contentStart = start.lastIndex;
|
|
33
|
+
let end = -1;
|
|
34
|
+
for (let quote = text.indexOf('"', contentStart); quote >= 0; quote = text.indexOf('"', quote + 1)) {
|
|
35
|
+
let slashCount = 0;
|
|
36
|
+
while (text[quote - slashCount - 1] === '\\') slashCount += 1;
|
|
37
|
+
if (slashCount >= depth && (slashCount - depth) % closeStride === 0) { end = quote + 1; break; }
|
|
38
|
+
}
|
|
39
|
+
if (end >= 0) matches.push({ start: match.index, end, key, value: text.slice(contentStart - depth - 1, end), escapes });
|
|
40
|
+
}
|
|
41
|
+
return matches;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readIdentifiers(file = DEFAULT_IDENTIFIERS) {
|
|
45
|
+
if (!fs.existsSync(file)) throw new Error(`No identifiers file at ${file}. List personal identifiers there, one per line, before submitting a run.`);
|
|
46
|
+
if ((fs.statSync(file).mode & 0o077) !== 0) throw new Error(`${file} must be readable by its owner only (chmod 600).`);
|
|
47
|
+
const values = fs.readFileSync(file, 'utf8').split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#'));
|
|
48
|
+
if (!values.length) throw new Error(`${file} lists no identifiers.`);
|
|
49
|
+
if (values.some(value => value.length < 6)) throw new Error(`${file}: every identifier must be at least 6 characters.`);
|
|
50
|
+
return values;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function walk(directory, links = [], paths = []) {
|
|
54
|
+
const files = fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
55
|
+
const file = path.join(directory, entry.name);
|
|
56
|
+
paths.push(file);
|
|
57
|
+
if (entry.isSymbolicLink()) { links.push(file); return []; }
|
|
58
|
+
if (entry.isDirectory()) return walk(file, links, paths).files;
|
|
59
|
+
return entry.isFile() ? [file] : [];
|
|
60
|
+
});
|
|
61
|
+
return { files, links, paths };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isText = bytes => !bytes.includes(0);
|
|
65
|
+
const replaceAll = (text, value, by) => text.split(value).join(by);
|
|
66
|
+
|
|
67
|
+
export function scrubPersonalData(text, { identifiers = [], home = os.homedir() } = {}) {
|
|
68
|
+
let clean = text.replace(CREATOR, (_, key) => `"${key}":"${REDACTED}"`);
|
|
69
|
+
for (const match of escapedCreators(clean).reverse()) clean = `${clean.slice(0, match.start)}${match.escapes}"${match.key}${match.escapes}":${match.escapes}"${REDACTED}${match.escapes}"${clean.slice(match.end)}`;
|
|
70
|
+
for (const identifier of identifiers) clean = replaceAll(clean, identifier, REDACTED);
|
|
71
|
+
return home && home.length > 1 ? replaceAll(clean, home, '~') : clean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Every finding names a file and a kind. `secrets` are provider-key values held by the runner.
|
|
75
|
+
export function findSecrets(directory, { secrets = [], stagingName = '' } = {}) {
|
|
76
|
+
const findings = [], values = secrets.filter(value => typeof value === 'string' && value.length >= 12), walked = walk(directory);
|
|
77
|
+
const scanName = (name, file) => {
|
|
78
|
+
for (const [kind, pattern] of KEY_PATTERNS) if (pattern.test(name)) findings.push({ file, kind: `${kind} in path` });
|
|
79
|
+
if (values.some(value => name.includes(value))) findings.push({ file, kind: 'a key from this environment in path' });
|
|
80
|
+
};
|
|
81
|
+
for (const item of walked.paths) scanName(path.relative(directory, item), path.relative(directory, item));
|
|
82
|
+
if (stagingName) scanName(stagingName, 'staged job name');
|
|
83
|
+
for (const file of walked.files) {
|
|
84
|
+
const text = fs.readFileSync(file).toString('latin1'), relative = path.relative(directory, file);
|
|
85
|
+
for (const [kind, pattern] of KEY_PATTERNS) if (pattern.test(text)) findings.push({ file: relative, kind });
|
|
86
|
+
if (values.some(value => text.includes(value))) findings.push({ file: relative, kind: 'a key from this environment' });
|
|
87
|
+
}
|
|
88
|
+
return findings;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function findPersonalData(directory, { identifiers = [], home = os.homedir(), stagingName = '' } = {}) {
|
|
92
|
+
const findings = [], values = identifiers.filter(value => typeof value === 'string' && value.length >= 6), { files, links, paths } = walk(directory);
|
|
93
|
+
const scan = (bytes, file) => {
|
|
94
|
+
values.forEach((value, index) => { if (bytes.includes(Buffer.from(value))) findings.push({ file, kind: `identifier ${index + 1}` }); });
|
|
95
|
+
if (home && home.length > 1 && bytes.includes(Buffer.from(home))) findings.push({ file, kind: 'the home directory' });
|
|
96
|
+
};
|
|
97
|
+
for (const item of paths) {
|
|
98
|
+
const relative = path.relative(directory, item);
|
|
99
|
+
scan(Buffer.from(relative), relative);
|
|
100
|
+
}
|
|
101
|
+
if (stagingName) scan(Buffer.from(stagingName), 'staged job name');
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
const bytes = fs.readFileSync(file), relative = path.relative(directory, file);
|
|
104
|
+
scan(bytes, relative);
|
|
105
|
+
if (!isText(bytes)) continue;
|
|
106
|
+
const text = bytes.toString('utf8');
|
|
107
|
+
for (const match of text.matchAll(CREATOR)) if (!keptCreatorValue(match[2])) findings.push({ file: relative, kind: `unredacted ${match[1]}` });
|
|
108
|
+
for (const match of escapedCreators(text)) if (!keptCreatorValue(match.value)) findings.push({ file: relative, kind: `unredacted ${match.key}` });
|
|
109
|
+
}
|
|
110
|
+
for (const link of links) {
|
|
111
|
+
const target = fs.readlinkSync(link), relative = `${path.relative(directory, link)} (link)`;
|
|
112
|
+
scan(Buffer.from(target), relative);
|
|
113
|
+
if (path.isAbsolute(target) || path.relative(directory, path.resolve(path.dirname(link), target)).split(path.sep)[0] === '..') {
|
|
114
|
+
findings.push({ file: relative, kind: 'unsafe symbolic link' });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return findings;
|
|
118
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "topo-workbench",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Workbench: the Model Topography benchmark
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Workbench: run the Model Topography benchmark on any model with Harbor, then submit the run for grading.",
|
|
5
5
|
"homepage": "https://modeltopography.com",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Dan Mirolli",
|
|
8
|
+
"type": "module",
|
|
8
9
|
"bin": { "topo-workbench": "bin/topo-workbench.js" },
|
|
9
|
-
"files": ["bin"],
|
|
10
|
-
"engines": { "node": ">=22" }
|
|
10
|
+
"files": ["bin", "lib"],
|
|
11
|
+
"engines": { "node": ">=22" },
|
|
12
|
+
"scripts": { "test": "node --test test/*.test.js" }
|
|
11
13
|
}
|