@arjunkhera/atlas 0.3.7 → 0.3.9
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/.claude-plugin/plugin.json +1 -1
- package/agents/artifact-renderer.md +22 -22
- package/agents/verifier.md +29 -0
- package/door/cli.mjs +62 -12
- package/door/kit-releases.json +4 -0
- package/door/lib/design-build.mjs +409 -0
- package/door/lib/design.mjs +199 -118
- package/door/lib/markdown.mjs +160 -0
- package/door/lib/proof.mjs +127 -0
- package/door/lib/tests.mjs +190 -0
- package/package.json +2 -1
- package/skills/lead/SKILL.md +4 -1
- package/skills/sdlc-task/SKILL.md +33 -24
- package/skills/sdlc-task/design/README.md +187 -0
- package/skills/sdlc-task/design/parts/actors.md +26 -0
- package/skills/sdlc-task/design/parts/alternatives.md +24 -0
- package/skills/sdlc-task/design/parts/build.md +23 -0
- package/skills/sdlc-task/design/parts/calls.md +25 -0
- package/skills/sdlc-task/design/parts/change.md +27 -0
- package/skills/sdlc-task/design/parts/data.md +22 -0
- package/skills/sdlc-task/design/parts/done.md +23 -0
- package/skills/sdlc-task/design/parts/edges.md +24 -0
- package/skills/sdlc-task/design/parts/goals.md +27 -0
- package/skills/sdlc-task/design/parts/key.md +25 -0
- package/skills/sdlc-task/design/parts/migration.md +22 -0
- package/skills/sdlc-task/design/parts/order.md +24 -0
- package/skills/sdlc-task/design/parts/problem.md +22 -0
- package/skills/sdlc-task/design/parts/proof.md +24 -0
- package/skills/sdlc-task/design/parts/proposal.md +24 -0
- package/skills/sdlc-task/design/parts/records.md +24 -0
- package/skills/sdlc-task/design/parts/repos.md +25 -0
- package/skills/sdlc-task/design/parts/risks.md +24 -0
- package/skills/sdlc-task/design/parts/rollout.md +24 -0
- package/skills/sdlc-task/design/parts/routes.md +24 -0
- package/skills/sdlc-task/design/parts/scorecard.md +25 -0
- package/skills/sdlc-task/design/parts/security.md +22 -0
- package/skills/sdlc-task/design/parts/shared-decisions.md +24 -0
- package/skills/sdlc-task/design/parts/states.md +25 -0
- package/skills/sdlc-task/design/parts/stories.md +26 -0
- package/skills/sdlc-task/design/parts/summary.md +33 -0
- package/skills/sdlc-task/design/parts/why.md +22 -0
- package/skills/sdlc-task/design/parts/words.md +29 -0
- package/skills/sdlc-task/design/parts/yardstick.md +25 -0
- package/skills/sdlc-task/design/parts.yaml +306 -0
- package/skills/sdlc-task/lifecycle.yaml +2 -2
- package/skills/tests/SKILL.md +234 -0
- package/tests/contract.mjs +398 -0
- package/tests/drivers/function.mjs +30 -0
- package/tests/drivers/http.mjs +68 -0
- package/tests/drivers/index.mjs +65 -0
- package/tests/drivers/mcp-stdio.mjs +174 -0
- package/tests/environment.mjs +227 -0
- package/tests/errors.mjs +27 -0
- package/tests/evidence.mjs +113 -0
- package/tests/fresh.mjs +42 -0
- package/tests/guards.mjs +159 -0
- package/tests/index.mjs +11 -0
- package/tests/link-check.mjs +576 -0
- package/tests/procs.mjs +43 -0
- package/tests/redact.mjs +58 -0
- package/tests/scenario.mjs +325 -0
- package/tests/stand-in.mjs +74 -0
- package/tests/tests-yaml.mjs +258 -0
- package/tests/wait.mjs +44 -0
- package/tests/yaml.mjs +327 -0
- package/agents/artifact-format/walkthrough.html +0 -706
- package/skills/sdlc-task/templates/design-doc.md +0 -126
package/tests/guards.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Guards: what a test may reach, and what a child process may see.
|
|
2
|
+
import { Blocked } from './errors.mjs';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const LOCAL_HOSTS = Object.freeze(['127.0.0.1', 'localhost', '::1']);
|
|
6
|
+
|
|
7
|
+
const bare = (host) => String(host).toLowerCase().replace(/^\[|\]$/g, '');
|
|
8
|
+
|
|
9
|
+
// The hosts that a test may reach: `guards.hosts`, or this machine only.
|
|
10
|
+
export function hostsOf(tests) {
|
|
11
|
+
const hosts = tests?.guards?.hosts;
|
|
12
|
+
return Array.isArray(hosts) && hosts.length ? hosts.map(String) : [...LOCAL_HOSTS];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hostAllowed(host, hosts) {
|
|
16
|
+
const h = bare(host);
|
|
17
|
+
return hosts.some((entry) => {
|
|
18
|
+
const e = bare(entry);
|
|
19
|
+
return e === h || (e.startsWith('*.') && h.endsWith(e.slice(1)));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Throws Blocked when the address is not on the list. The result is `blocked`,
|
|
24
|
+
// never `fail`.
|
|
25
|
+
export function requireAllowed(url, hosts, what = 'a request') {
|
|
26
|
+
let parsed;
|
|
27
|
+
try { parsed = new URL(url); } catch { throw new Blocked(`${what}: "${String(url).slice(0, 80)}" is not an address`); }
|
|
28
|
+
if (!hostAllowed(parsed.hostname, hosts)) {
|
|
29
|
+
throw new Blocked(`${what} to ${parsed.origin} was refused: guards.hosts allows only ${hosts.join(', ')}`);
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const sameOrigin = (a, b) => new URL(a).origin === new URL(b).origin;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------- child env
|
|
37
|
+
|
|
38
|
+
// A child process starts from an ALLOW-LIST, not from the parent environment
|
|
39
|
+
// minus a few names (design section 15, rule 1). It gets these names from the
|
|
40
|
+
// parent and nothing else: PATH, HOME, LANG, LC_*, TMPDIR, TERM and NODE_*
|
|
41
|
+
// (such as NODE_EXTRA_CA_CERTS). The rule follows packages/atlas/work/lib/child-env.mjs.
|
|
42
|
+
// The proxy variables pass only in a Claude cloud session, and only when the
|
|
43
|
+
// proxy is a loopback address. NODE_TEST_CONTEXT never passes.
|
|
44
|
+
const ALLOWED = /^(?:PATH|HOME|LANG|LC_[A-Z_]+|TMPDIR|TERM)$/;
|
|
45
|
+
const CLOUD_PROXY = ['HTTPS_PROXY', 'https_proxy'];
|
|
46
|
+
const CLOUD_NO_PROXY = ['NO_PROXY', 'no_proxy'];
|
|
47
|
+
const LOOPBACK_PROXY = /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d{1,5}\/?$/;
|
|
48
|
+
|
|
49
|
+
// The environment of a child process: the allow-list from `parent`, then
|
|
50
|
+
// `values` on top. A value in `values` replaces any parent value of the same
|
|
51
|
+
// name, so a parent value for a name that tests.yaml sets never reaches a child.
|
|
52
|
+
export function childEnv(parent = process.env, values = {}) {
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const [name, value] of Object.entries(parent)) {
|
|
55
|
+
if (value === undefined) continue;
|
|
56
|
+
if (ALLOWED.test(name) || (name.startsWith('NODE_') && name !== 'NODE_TEST_CONTEXT')) out[name] = value;
|
|
57
|
+
}
|
|
58
|
+
if (parent.CLAUDE_CODE_REMOTE === 'true') {
|
|
59
|
+
const proxies = CLOUD_PROXY.filter((name) => parent[name] !== undefined);
|
|
60
|
+
if (proxies.length && proxies.every((name) => LOOPBACK_PROXY.test(parent[name]))) {
|
|
61
|
+
for (const name of [...proxies, ...CLOUD_NO_PROXY]) if (parent[name] !== undefined) out[name] = parent[name];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const [name, value] of Object.entries(values)) if (value !== undefined) out[name] = String(value);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------- addresses
|
|
69
|
+
|
|
70
|
+
const ADDRESS_NAME = /(?:URL|URI|HOST|ADDR|ADDRESS|ENDPOINT)/i;
|
|
71
|
+
const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\/\S+/i;
|
|
72
|
+
const HOST_PORT = /^(\[[0-9a-f:.]+\]|[a-z0-9][a-z0-9.-]*):(\d{1,5})$/i;
|
|
73
|
+
|
|
74
|
+
// The host of one token, when the token is an address: a URL of any scheme
|
|
75
|
+
// (a file URL has no host and is no address), or host:port. Returns
|
|
76
|
+
// undefined when the token is not an address, and null when it is a URL that
|
|
77
|
+
// does not parse.
|
|
78
|
+
function hostOfToken(token) {
|
|
79
|
+
if (SCHEME_URL.test(token)) {
|
|
80
|
+
try { return new URL(token).hostname || undefined; } catch { return null; }
|
|
81
|
+
}
|
|
82
|
+
const m = HOST_PORT.exec(token);
|
|
83
|
+
if (!m) return undefined;
|
|
84
|
+
const host = m[1];
|
|
85
|
+
// "12:30" is a time, not an address: a host needs a letter, a dot or brackets.
|
|
86
|
+
if (!/[a-z.\[]/i.test(host)) return undefined;
|
|
87
|
+
return host;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The addresses in an env map. A value is an address when it parses as a URL
|
|
91
|
+
// of any scheme or as host:port, whatever its name. A value may hold a list
|
|
92
|
+
// (split on commas, semicolons and white space). With `byName`, the value of
|
|
93
|
+
// a variable whose name says it is an address (URL, HOST, ...) is one too,
|
|
94
|
+
// even when it parses as nothing.
|
|
95
|
+
export function addressesIn(env = {}, { byName = true } = {}) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const [key, value] of Object.entries(env)) {
|
|
98
|
+
const text = String(value ?? '').trim();
|
|
99
|
+
let found = false;
|
|
100
|
+
for (const token of text.split(/[\s,;]+/).filter(Boolean)) {
|
|
101
|
+
const host = hostOfToken(token);
|
|
102
|
+
if (host === undefined) continue;
|
|
103
|
+
found = true;
|
|
104
|
+
out.push({ key, value: token, host });
|
|
105
|
+
}
|
|
106
|
+
if (!found && byName && ADDRESS_NAME.test(key)) {
|
|
107
|
+
out.push({ key, value: text, host: text.replace(/:\d+$/, '').trim() || null });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Every address in `env` must be on the list (`blocked` otherwise). Used for
|
|
114
|
+
// the vars of an environment and for the env of a way in.
|
|
115
|
+
export function requireAddressesAllowed(env, hosts, what) {
|
|
116
|
+
for (const one of addressesIn(env, { byName: false })) {
|
|
117
|
+
if (!one.host) throw new Blocked(`${what}: ${one.key} holds an address that cannot be read`);
|
|
118
|
+
if (!hostAllowed(one.host, hosts)) throw new Blocked(`${what}: ${one.key} points at ${one.host}, which guards.hosts does not allow (it allows only ${hosts.join(', ')})`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// For a process that the library starts for a test, such as an MCP server:
|
|
123
|
+
// every address in the env that the library gives it must be on the list, and
|
|
124
|
+
// there must be one. A server with no address could fall back to a real one.
|
|
125
|
+
// `given` is what the way in sets. `full` is the whole env the server gets;
|
|
126
|
+
// the list check runs on all of it.
|
|
127
|
+
export function requireLocalAddresses(given, hosts, what, full = given) {
|
|
128
|
+
const own = addressesIn(given);
|
|
129
|
+
if (!own.length) throw new Blocked(`${what}: the env gives the server no address, so it could fall back to a real one; give it the local address`);
|
|
130
|
+
for (const one of addressesIn(full)) {
|
|
131
|
+
if (!one.host) throw new Blocked(`${what}: ${one.key} holds no address, so the server could fall back to a real one`);
|
|
132
|
+
if (!hostAllowed(one.host, hosts)) throw new Blocked(`${what}: ${one.key} points at ${one.host}, which guards.hosts does not allow`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------- templates
|
|
137
|
+
|
|
138
|
+
// Replaces the placeholders of tests.yaml. Known forms:
|
|
139
|
+
// {run.secret} {run.id} {run.vars} {run.vars.NAME} {stand-in.NAME.url} {url} {port}
|
|
140
|
+
// `ctx` gives a function for each: secret(), id, varsFile, vars(name), standIn(name), url, port.
|
|
141
|
+
const PLACEHOLDER = /\{(run\.secret|run\.id|run\.vars(?:\.[A-Za-z0-9_]+)?|stand-in\.[A-Za-z0-9_-]+\.url|url|port)\}/g;
|
|
142
|
+
|
|
143
|
+
export function substitute(text, ctx) {
|
|
144
|
+
return String(text).replace(PLACEHOLDER, (whole, key) => {
|
|
145
|
+
let value;
|
|
146
|
+
if (key === 'run.secret') value = ctx.secret?.();
|
|
147
|
+
else if (key === 'run.id') value = ctx.id;
|
|
148
|
+
else if (key === 'run.vars') value = ctx.varsFile;
|
|
149
|
+
else if (key.startsWith('run.vars.')) value = ctx.vars?.(key.slice(9));
|
|
150
|
+
else if (key.startsWith('stand-in.')) value = ctx.standIn?.(key.slice(9, -4));
|
|
151
|
+
else if (key === 'url') value = ctx.url;
|
|
152
|
+
else if (key === 'port') value = ctx.port;
|
|
153
|
+
if (value === undefined || value === null || value === '') throw new Error(`no value for the placeholder ${whole}`);
|
|
154
|
+
return String(value);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A fresh run secret: random, 40 characters.
|
|
159
|
+
export const newSecret = () => randomBytes(30).toString('base64url').slice(0, 40);
|
package/tests/index.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The public face of the kit. A repo imports from here, or from the files directly.
|
|
2
|
+
export { scenario, defineAdapter, waitUntil, RUN_ID } from './contract.mjs';
|
|
3
|
+
export { startEnvironment, parseDotenv, provideSecrets } from './environment.mjs';
|
|
4
|
+
export { createStandIn, serveStandIn, CALLS_PATH, LIBRARY_HEADER } from './stand-in.mjs';
|
|
5
|
+
export { createHttpDriver, createMcpStdioDriver, createFunctionDriver, makeDriver } from './drivers/index.mjs';
|
|
6
|
+
export { parseTestsYaml, readTestsYaml, checkTestsYaml, guardParts, compareGuardParts, GUARD_PARTS, looksLikeSecret } from './tests-yaml.mjs';
|
|
7
|
+
export { parseScenario, readScenario, addFingerprints, FINGERPRINT_METHOD } from './scenario.mjs';
|
|
8
|
+
export { Blocked, Unavailable, WaitFailed, WaitTimeout, GateFailed } from './errors.mjs';
|
|
9
|
+
export { Redactor } from './redact.mjs';
|
|
10
|
+
export { Evidence, EVIDENCE_SCHEMA, RESULTS } from './evidence.mjs';
|
|
11
|
+
export { checkArea } from './link-check.mjs';
|