@codedology/code-forge 0.1.0
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/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `models --refresh --from-cli-caches` (plan §2 step 3, §1.3, C15 — un-cut from v1.1's original
|
|
3
|
+
* "returns as an opt-in"). Reads each CLI's own model cache off disk (Codex's `~/.codex/
|
|
4
|
+
* models_cache.json`, Grok's `~/.grok/models_cache.json` — whichever are present) and merges the
|
|
5
|
+
* ids into a **user-level** cache written by THIS module, `~/.code-forge/cache/models.json`,
|
|
6
|
+
* shaped `{status: "seen-in-cache", updated_at, seen: {<provider>: [<id>, ...]}}`. It never
|
|
7
|
+
* touches the project's `.code-forge.yml` — a model seen this way is only ever a WARNING-
|
|
8
|
+
* suppressing fact for `validate.mjs`'s `unknown-model-id` rule (like `known_extra`, but
|
|
9
|
+
* per-machine instead of per-project), never something this module writes into the config a
|
|
10
|
+
* project commits.
|
|
11
|
+
*
|
|
12
|
+
* `home` is always an explicit parameter (default `os.homedir()`), never hardcoded — the only way
|
|
13
|
+
* a test can point this at a fixture HOME without touching the real `~/.codex`/`~/.grok`.
|
|
14
|
+
*
|
|
15
|
+
* **Real cache shapes** (read once from this machine's own `~/.codex/models_cache.json` and
|
|
16
|
+
* `~/.grok/models_cache.json`, 2026-09-24, to build the fixtures under `test/fixtures/cli-caches/`
|
|
17
|
+
* — no secret, identity, etag or auth-looking field was copied into either fixture):
|
|
18
|
+
* - Codex: `{fetched_at, etag, client_version, identity, models: [{slug, display_name,
|
|
19
|
+
* supported_reasoning_levels: [{effort, description}, ...], ...many more fields}, ...]}` — an
|
|
20
|
+
* ARRAY of model OBJECTS, id at `.slug`.
|
|
21
|
+
* - Grok: `{fetched_at, grok_version, auth_method, origin, identity, etag, models: {<id>:
|
|
22
|
+
* {info: {id, model, model_family, reasoning_efforts: [{id, value, label, ...}], ...}, api_key,
|
|
23
|
+
* env_key, api_base_url}, ...}}` — an OBJECT keyed by id, id at `.info.id` (falls back to the
|
|
24
|
+
* object's own key if `info.id` is absent).
|
|
25
|
+
* Both extractors also accept a bare `["id", ...]` array or `{"models": ["id", ...]}` — a
|
|
26
|
+
* deliberately simple form kept for hand-written test fixtures and for forward compatibility with
|
|
27
|
+
* a future CLI cache that already lists bare id strings.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
31
|
+
import os from 'node:os';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {(parsed: unknown) => string[]} ModelIdExtractor
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Codex's real shape: `models` is an ARRAY of objects; the id is `.slug`. Also accepts the
|
|
41
|
+
* simple array-of-strings / `{models: [string]}` forms.
|
|
42
|
+
* @type {ModelIdExtractor}
|
|
43
|
+
*/
|
|
44
|
+
function extractCodexModelIds(parsed) {
|
|
45
|
+
const models = Array.isArray(parsed) ? parsed : /** @type {any} */ (parsed)?.models;
|
|
46
|
+
if (!Array.isArray(models)) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return models
|
|
50
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry?.slug))
|
|
51
|
+
.filter((id) => typeof id === 'string' && id.length > 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Grok's real shape: `models` is an OBJECT keyed by id; the id is `.info.id` (or the key itself
|
|
56
|
+
* when `info.id` is missing/malformed). Also accepts the simple array-of-strings /
|
|
57
|
+
* `{models: [string]}` forms.
|
|
58
|
+
* @type {ModelIdExtractor}
|
|
59
|
+
*/
|
|
60
|
+
function extractGrokModelIds(parsed) {
|
|
61
|
+
if (Array.isArray(parsed)) {
|
|
62
|
+
return parsed.filter((id) => typeof id === 'string');
|
|
63
|
+
}
|
|
64
|
+
const models = /** @type {any} */ (parsed)?.models;
|
|
65
|
+
if (Array.isArray(models)) {
|
|
66
|
+
return models.filter((id) => typeof id === 'string');
|
|
67
|
+
}
|
|
68
|
+
if (models && typeof models === 'object') {
|
|
69
|
+
return Object.entries(models)
|
|
70
|
+
.map(([key, entry]) => (typeof entry?.info?.id === 'string' && entry.info.id.length > 0 ? entry.info.id : key))
|
|
71
|
+
.filter((id) => typeof id === 'string' && id.length > 0);
|
|
72
|
+
}
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Where each CLI keeps its own model cache, relative to `$HOME`, and how to read its ids. */
|
|
77
|
+
const CLI_CACHE_SOURCES = Object.freeze([
|
|
78
|
+
{ provider: 'openai', relativePath: path.join('.codex', 'models_cache.json'), extract: extractCodexModelIds },
|
|
79
|
+
{ provider: 'xai', relativePath: path.join('.grok', 'models_cache.json'), extract: extractGrokModelIds },
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} home
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
export function userModelsCachePath(home = os.homedir()) {
|
|
87
|
+
return path.join(home, '.code-forge', 'cache', 'models.json');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @typedef {object} RefreshResult
|
|
92
|
+
* @property {string[]} sourcesRead - absolute paths of the CLI caches that existed, parsed as
|
|
93
|
+
* valid JSON, AND yielded at least one id.
|
|
94
|
+
* @property {{path: string, reason: string}[]} sourcesSkipped - a CLI cache that existed but
|
|
95
|
+
* could not be used — unreadable (`unreadable (EISDIR)`), malformed JSON (`not valid JSON`,
|
|
96
|
+
* optionally `(at position N)` — never a source excerpt), or parsed but yielded 0 ids (a shape
|
|
97
|
+
* this module doesn't recognise). A source being skipped never aborts the other source.
|
|
98
|
+
* @property {Record<string, string[]>} seenThisRun - provider -> ids read from THIS run's sources only.
|
|
99
|
+
* @property {string} cachePath - where the merged user-level cache was written.
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {{home?: string}} [opts]
|
|
104
|
+
* @returns {Promise<RefreshResult>}
|
|
105
|
+
*/
|
|
106
|
+
export async function refreshFromCliCaches(opts = {}) {
|
|
107
|
+
const home = opts.home ?? os.homedir();
|
|
108
|
+
/** @type {Record<string, string[]>} */
|
|
109
|
+
const seenThisRun = {};
|
|
110
|
+
/** @type {string[]} */
|
|
111
|
+
const sourcesRead = [];
|
|
112
|
+
/** @type {{path: string, reason: string}[]} */
|
|
113
|
+
const sourcesSkipped = [];
|
|
114
|
+
|
|
115
|
+
for (const { provider, relativePath, extract } of CLI_CACHE_SOURCES) {
|
|
116
|
+
const cachePath = path.join(home, relativePath);
|
|
117
|
+
let raw;
|
|
118
|
+
try {
|
|
119
|
+
raw = await readFile(cachePath, 'utf8');
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
122
|
+
if (code === 'ENOENT') {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// EACCES, EISDIR, EMFILE, … — skipped like a malformed file, never aborting the OTHER
|
|
126
|
+
// provider's valid cache (round-2 MINOR). Only the errno code is reported.
|
|
127
|
+
sourcesSkipped.push({ path: cachePath, reason: `unreadable (${safeErrnoCode(code)})` });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let parsed;
|
|
132
|
+
try {
|
|
133
|
+
parsed = JSON.parse(raw);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// A malformed source (e.g. the CLI is partway through writing it) never aborts the other
|
|
136
|
+
// provider's valid cache — skip and record why, keep going. NEVER `err.message`: V8's
|
|
137
|
+
// SyntaxError quotes a slice of the source text, and Grok's real cache stores `api_key` in
|
|
138
|
+
// every model entry, so a half-written file would put a key into this reason (round-2
|
|
139
|
+
// MAJOR). Only the numeric position, parsed out, is kept.
|
|
140
|
+
sourcesSkipped.push({ path: cachePath, reason: `not valid JSON${jsonErrorPosition(err)}` });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ids = extract(parsed);
|
|
145
|
+
if (ids.length === 0) {
|
|
146
|
+
// Parsed fine but yielded nothing — an unrecognised shape is reported, never silently
|
|
147
|
+
// recorded as a successful empty refresh.
|
|
148
|
+
sourcesSkipped.push({ path: cachePath, reason: 'parsed but no model ids could be read from it' });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
seenThisRun[provider] = ids;
|
|
152
|
+
sourcesRead.push(cachePath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const cachePath = userModelsCachePath(home);
|
|
156
|
+
const existingSeen = await readExistingSeen(cachePath);
|
|
157
|
+
|
|
158
|
+
/** @type {Record<string, string[]>} */
|
|
159
|
+
const mergedSeen = { ...existingSeen };
|
|
160
|
+
for (const [provider, ids] of Object.entries(seenThisRun)) {
|
|
161
|
+
const merged = new Set(Object.hasOwn(mergedSeen, provider) ? mergedSeen[provider] : []);
|
|
162
|
+
for (const id of ids) {
|
|
163
|
+
merged.add(id);
|
|
164
|
+
}
|
|
165
|
+
mergedSeen[provider] = [...merged].sort();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await writeModelsCacheAtomically(cachePath, {
|
|
169
|
+
status: 'seen-in-cache',
|
|
170
|
+
updated_at: new Date().toISOString(),
|
|
171
|
+
seen: mergedSeen,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return { sourcesRead, sourcesSkipped, seenThisRun, cachePath };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {unknown} code
|
|
179
|
+
* @returns {string} an errno code (`EISDIR`) when it has the errno shape, else `unknown error` —
|
|
180
|
+
* the reason string must never carry free text.
|
|
181
|
+
*/
|
|
182
|
+
function safeErrnoCode(code) {
|
|
183
|
+
return typeof code === 'string' && /^E[A-Z0-9]+$/.test(code) ? code : 'unknown error';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {unknown} err - a `JSON.parse` SyntaxError.
|
|
188
|
+
* @returns {string} ` (at position N)` when V8 reported one, else `''` — digits only, never the
|
|
189
|
+
* quoted source excerpt V8 also puts in the message.
|
|
190
|
+
*/
|
|
191
|
+
function jsonErrorPosition(err) {
|
|
192
|
+
const message = err instanceof Error ? err.message : '';
|
|
193
|
+
const match = /\bposition (\d+)\b/.exec(message);
|
|
194
|
+
return match ? ` (at position ${match[1]})` : '';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A provider key that must never be written into a map — it names the prototype, not a provider. */
|
|
198
|
+
const FORBIDDEN_PROVIDER_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Reads the existing user-level cache's `seen` map, tolerating a missing, unreadable, corrupt, or
|
|
202
|
+
* wrongly-shaped file by discarding it (never crashing a refresh — or a `validate` — over it).
|
|
203
|
+
* Only keeps `seen.<provider>` entries that are themselves arrays of strings — a string value
|
|
204
|
+
* (`new Set("abc")` would silently split it into characters) or any other shape is dropped rather
|
|
205
|
+
* than trusted. `__proto__`/`constructor`/`prototype` keys are dropped (round-2 MINOR): JSON.parse
|
|
206
|
+
* makes `"__proto__"` an OWN key, and assigning it into a plain object would replace that
|
|
207
|
+
* object's prototype with the id array. (A plain object, not a null-prototype one, on purpose:
|
|
208
|
+
* callers and tests compare the result against `{}` with strict deep equality.)
|
|
209
|
+
* @param {string} cachePath
|
|
210
|
+
* @returns {Promise<Record<string, string[]>>}
|
|
211
|
+
*/
|
|
212
|
+
async function readExistingSeen(cachePath) {
|
|
213
|
+
/** @type {Record<string, string[]>} */
|
|
214
|
+
const cleaned = {};
|
|
215
|
+
let raw;
|
|
216
|
+
try {
|
|
217
|
+
raw = await readFile(cachePath, 'utf8');
|
|
218
|
+
} catch {
|
|
219
|
+
// ENOENT (never refreshed), EISDIR, EACCES, … — every read error means "nothing usable seen".
|
|
220
|
+
// A refresh then fails loudly at its own write step if the path is genuinely unwritable.
|
|
221
|
+
return cleaned;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let parsed;
|
|
225
|
+
try {
|
|
226
|
+
parsed = JSON.parse(raw);
|
|
227
|
+
} catch {
|
|
228
|
+
return cleaned; // corrupt JSON — a refresh is about to overwrite it with a valid file anyway.
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const seen = parsed && typeof parsed === 'object' ? /** @type {any} */ (parsed).seen : undefined;
|
|
232
|
+
if (!seen || typeof seen !== 'object' || Array.isArray(seen)) {
|
|
233
|
+
return cleaned;
|
|
234
|
+
}
|
|
235
|
+
for (const [provider, ids] of Object.entries(seen)) {
|
|
236
|
+
if (FORBIDDEN_PROVIDER_KEYS.has(provider)) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (Array.isArray(ids) && ids.every((id) => typeof id === 'string')) {
|
|
240
|
+
cleaned[provider] = ids;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return cleaned;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Writes `contents` to `cachePath` atomically: write to a sibling temp file, then `rename()` it
|
|
248
|
+
* over the target. `rename()` within the same directory is a single filesystem operation, so a
|
|
249
|
+
* reader never observes a partially-written file, and an interrupted write leaves the ORIGINAL
|
|
250
|
+
* file (or no file) intact rather than a truncated one.
|
|
251
|
+
* @param {string} cachePath
|
|
252
|
+
* @param {Record<string, any>} contents
|
|
253
|
+
*/
|
|
254
|
+
async function writeModelsCacheAtomically(cachePath, contents) {
|
|
255
|
+
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
256
|
+
const tempPath = path.join(path.dirname(cachePath), `.models.json.${randomUUID()}.tmp`);
|
|
257
|
+
await writeFile(tempPath, `${JSON.stringify(contents, null, 2)}\n`, 'utf8');
|
|
258
|
+
try {
|
|
259
|
+
await rename(tempPath, cachePath);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
await unlink(tempPath).catch(() => {});
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Loads the merged user-level cache written by {@link refreshFromCliCaches}, for `validate.mjs`'s
|
|
268
|
+
* `unknown-model-id` rule to pass in as `opts.seenInCache`. Returns `{}` when the cache has never
|
|
269
|
+
* been written, is corrupt, or is wrongly shaped (a project that never ran `--refresh-models`, or
|
|
270
|
+
* whose cache file got damaged, sees the same warnings it always did — never a crash).
|
|
271
|
+
* @param {{home?: string}} [opts]
|
|
272
|
+
* @returns {Promise<Record<string, string[]>>}
|
|
273
|
+
*/
|
|
274
|
+
export async function loadSeenInCache(opts = {}) {
|
|
275
|
+
return readExistingSeen(userModelsCachePath(opts.home ?? os.homedir()));
|
|
276
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-provider schema compilation (plan §1.3, §5.2, O36) — the S2 session's answer schema and
|
|
3
|
+
* (in a later block) `s2.mjs`'s compiled-schema-per-provider both need this.
|
|
4
|
+
*
|
|
5
|
+
* OpenAI's structured-output "strict" mode has three rules a hand-written schema violates by
|
|
6
|
+
* default: (1) every property under an object must be listed in `required` (optional fields are
|
|
7
|
+
* instead modeled as **nullable** required fields), (2) every object must set
|
|
8
|
+
* `additionalProperties: false`, (3) nothing may be missing from `required`. Claude and Grok take
|
|
9
|
+
* the source schema unchanged (they support ordinary JSON Schema `required`/optional).
|
|
10
|
+
*
|
|
11
|
+
* **Nullability.** `makeNullable()` does ONE thing, unconditionally, for anything it cannot PROVE
|
|
12
|
+
* already accepts null: move the WHOLE original sub-schema into one `anyOf` branch and add
|
|
13
|
+
* `{type: 'null'}` as the other. That is correct regardless of what was inside — enum, const, an
|
|
14
|
+
* object, a `$ref`, a `not`, an `if/then/else`, a nested `anyOf` — because `null` gets its own
|
|
15
|
+
* branch that doesn't depend on anything the original constrains. `schemaAcceptsNull()` is
|
|
16
|
+
* deliberately conservative (round-2 MINOR): a sibling `type`/`const`/`enum` that excludes null
|
|
17
|
+
* wins over a union branch that allows it (`{type:'object', anyOf:[…,{type:'null'}]}` rejects
|
|
18
|
+
* null), and `$ref`/`not`/`if`/`then`/`else` are never "proven" — wrapping a schema that already
|
|
19
|
+
* accepted null is harmless, skipping one that didn't is the bug.
|
|
20
|
+
*
|
|
21
|
+
* **`additionalProperties: false`.** Every object node that owns a `properties` map gets
|
|
22
|
+
* `additionalProperties: false` unless it already declares an open-ended one, and a bare
|
|
23
|
+
* `{type: 'object'}` with no `properties` and no `additionalProperties` is closed to
|
|
24
|
+
* `{properties: {}, required: [], additionalProperties: false}`. A node that stays open-ended
|
|
25
|
+
* (`additionalProperties: true` or a schema — one of this project's deliberately open maps such as
|
|
26
|
+
* `thresholds`, `known_extra`) has NO representation in OpenAI strict mode; forcing it to `false`
|
|
27
|
+
* would silently drop the map semantics the source intends. Such nodes are left as they are and
|
|
28
|
+
* reported by {@link openAIStrictViolations}; `compileSchema(…, 'openai', {strict: true})` throws
|
|
29
|
+
* naming every one. B9's S2 answer schema has none; only the full project config schema (this
|
|
30
|
+
* package's own test) does.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {Record<string, any>} SchemaNode - see `schema-paths.mjs`'s typedef of the same name —
|
|
35
|
+
* a schema fragment's shape genuinely varies (`properties`, `$ref`, `anyOf`, `$defs`, …).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {SchemaNode} schema
|
|
40
|
+
* @param {"anthropic" | "openai" | "xai"} provider
|
|
41
|
+
* @param {{strict?: boolean}} [opts] - `strict: true` (openai only) throws when the compiled
|
|
42
|
+
* schema still has a node OpenAI strict mode cannot accept, naming each JSON pointer.
|
|
43
|
+
* @returns {SchemaNode} a fresh, independent schema object — never the same reference as `schema`.
|
|
44
|
+
*/
|
|
45
|
+
export function compileSchema(schema, provider, opts = {}) {
|
|
46
|
+
const clone = structuredClone(schema);
|
|
47
|
+
if (provider !== 'openai') {
|
|
48
|
+
return clone;
|
|
49
|
+
}
|
|
50
|
+
compileForOpenAI(clone, new Set());
|
|
51
|
+
if (opts.strict === true) {
|
|
52
|
+
const violations = openAIStrictViolations(clone);
|
|
53
|
+
if (violations.length > 0) {
|
|
54
|
+
throw new Error(`schema cannot be made OpenAI-strict at: ${violations.join(', ')} (open-ended object: additionalProperties is not false)`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return clone;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {SchemaNode | boolean} schema - a boolean schema (`true`/`false`) is legal JSON Schema.
|
|
62
|
+
* @returns {boolean} true only when `schema` provably validates `null`. Conservative: anything it
|
|
63
|
+
* cannot prove (a `$ref`, `not`, `if`/`then`/`else`) counts as "does not accept null".
|
|
64
|
+
*/
|
|
65
|
+
function schemaAcceptsNull(schema) {
|
|
66
|
+
if (schema === true) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (!schema || typeof schema !== 'object') {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if ('type' in schema) {
|
|
73
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
74
|
+
if (!types.includes('null')) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if ('const' in schema && schema.const !== null) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(schema.enum) && !schema.enum.includes(null)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
for (const unprovable of ['$ref', 'not', 'if', 'then', 'else']) {
|
|
85
|
+
if (unprovable in schema) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (Array.isArray(schema.anyOf) && !schema.anyOf.some(schemaAcceptsNull)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(schema.oneOf) && schema.oneOf.filter(schemaAcceptsNull).length !== 1) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(schema.allOf) && !schema.allOf.every(schemaAcceptsNull)) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Makes `propSchema` nullable, in place, unconditionally wrapping whatever it already was.
|
|
103
|
+
* @param {SchemaNode} propSchema
|
|
104
|
+
*/
|
|
105
|
+
function makeNullable(propSchema) {
|
|
106
|
+
if (!propSchema || typeof propSchema !== 'object' || schemaAcceptsNull(propSchema)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
/** @type {SchemaNode} */
|
|
110
|
+
const original = {};
|
|
111
|
+
for (const key of Object.keys(propSchema)) {
|
|
112
|
+
original[key] = propSchema[key];
|
|
113
|
+
delete propSchema[key];
|
|
114
|
+
}
|
|
115
|
+
propSchema.anyOf = [original, { type: 'null' }];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {SchemaNode} node
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
function isObjectTyped(node) {
|
|
123
|
+
return node.type === 'object' || (Array.isArray(node.type) && node.type.includes('object'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every child schema of `node` with its JSON-pointer suffix — the ONE traversal both the compiler
|
|
128
|
+
* and {@link openAIStrictViolations} use, so neither can skip a keyword the other visits.
|
|
129
|
+
* @param {SchemaNode} node
|
|
130
|
+
* @returns {Array<[string, SchemaNode]>}
|
|
131
|
+
*/
|
|
132
|
+
function childSchemas(node) {
|
|
133
|
+
/** @type {Array<[string, SchemaNode]>} */
|
|
134
|
+
const out = [];
|
|
135
|
+
const push = (/** @type {string} */ suffix, /** @type {unknown} */ child) => {
|
|
136
|
+
if (child && typeof child === 'object') {
|
|
137
|
+
out.push([suffix, /** @type {SchemaNode} */ (child)]);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
for (const [key, child] of Object.entries(node.properties ?? {})) push(`/properties/${key}`, child);
|
|
141
|
+
for (const keyword of ['anyOf', 'oneOf', 'allOf', 'prefixItems']) {
|
|
142
|
+
if (Array.isArray(node[keyword])) node[keyword].forEach((child, i) => push(`/${keyword}/${i}`, child));
|
|
143
|
+
}
|
|
144
|
+
for (const keyword of ['items', 'additionalProperties', 'not', 'if', 'then', 'else']) push(`/${keyword}`, node[keyword]);
|
|
145
|
+
for (const keyword of ['patternProperties', '$defs', 'definitions']) {
|
|
146
|
+
for (const [key, child] of Object.entries(node[keyword] ?? {})) push(`/${keyword}/${key}`, child);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Recursively rewrites every object node: a `properties` map makes every property `required` and
|
|
153
|
+
* nullable and closes the node (unless it already has an open-ended `additionalProperties`); a
|
|
154
|
+
* bare `{type: 'object'}` is closed to an empty strict object. `visited` guards against revisiting
|
|
155
|
+
* the same shared node twice — not against a genuine schema cycle, which this project's schema
|
|
156
|
+
* does not have.
|
|
157
|
+
* @param {SchemaNode} node
|
|
158
|
+
* @param {Set<SchemaNode>} visited
|
|
159
|
+
*/
|
|
160
|
+
function compileForOpenAI(node, visited) {
|
|
161
|
+
if (!node || typeof node !== 'object' || visited.has(node)) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
visited.add(node);
|
|
165
|
+
|
|
166
|
+
if (node.properties && typeof node.properties === 'object') {
|
|
167
|
+
const keys = Object.keys(node.properties);
|
|
168
|
+
node.required = keys;
|
|
169
|
+
if (node.additionalProperties === undefined) {
|
|
170
|
+
node.additionalProperties = false;
|
|
171
|
+
}
|
|
172
|
+
for (const key of keys) {
|
|
173
|
+
makeNullable(node.properties[key]);
|
|
174
|
+
}
|
|
175
|
+
} else if (isObjectTyped(node) && node.additionalProperties === undefined && node.patternProperties === undefined) {
|
|
176
|
+
node.properties = {};
|
|
177
|
+
node.required = [];
|
|
178
|
+
node.additionalProperties = false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const [, child] of childSchemas(node)) {
|
|
182
|
+
compileForOpenAI(child, visited);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Every object node in an (already compiled) schema that OpenAI strict mode cannot accept: an
|
|
188
|
+
* object-typed or `properties`-owning node whose `additionalProperties` is not exactly `false`.
|
|
189
|
+
* @param {SchemaNode} compiled
|
|
190
|
+
* @returns {string[]} JSON pointers (`#/properties/thresholds`), in traversal order.
|
|
191
|
+
*/
|
|
192
|
+
export function openAIStrictViolations(compiled) {
|
|
193
|
+
/** @type {string[]} */
|
|
194
|
+
const out = [];
|
|
195
|
+
/** @type {Set<SchemaNode>} */
|
|
196
|
+
const visited = new Set();
|
|
197
|
+
const walk = (/** @type {SchemaNode} */ node, /** @type {string} */ pointer) => {
|
|
198
|
+
if (!node || typeof node !== 'object' || visited.has(node)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
visited.add(node);
|
|
202
|
+
if ((isObjectTyped(node) || (node.properties && typeof node.properties === 'object')) && node.additionalProperties !== false) {
|
|
203
|
+
out.push(pointer);
|
|
204
|
+
}
|
|
205
|
+
for (const [suffix, child] of childSchemas(node)) {
|
|
206
|
+
walk(child, `${pointer}${suffix}`);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
walk(compiled, '#');
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, shared JSON-Schema navigator used by tests (schema-coverage, the config-contract path
|
|
3
|
+
* enumeration) and by `schema-compile.mjs`. Handles exactly what `schema/code-forge.schema.json`
|
|
4
|
+
* actually uses between two dotted-path segments: a `properties` lookup, a typed
|
|
5
|
+
* `additionalProperties` map (`thresholds.<question>`, `known_extra.<provider>`, `keys.<name>`), a
|
|
6
|
+
* `$ref` (`levels.L0` points at `#/$defs/level`), and an `anyOf` branch (`review.second_levels` is
|
|
7
|
+
* `anyOf: [null, {additionalProperties: level}]` — nullable-but-otherwise-a-map). Nothing richer
|
|
8
|
+
* than that (no `oneOf`/`allOf`) appears anywhere in this schema. **B1.1 (v1.3)** added
|
|
9
|
+
* `review.max_rounds_per_file`, `review.recheck_scope`, `review.late_findings`, `budget.block_cases`
|
|
10
|
+
* and `tmp.root` — each is a plain `properties` lookup (`tmp.root` an `anyOf: [string, null]` leaf,
|
|
11
|
+
* the same shape `review.second_levels` already exercises below) — no new traversal shape needed.
|
|
12
|
+
*
|
|
13
|
+
* @typedef {Record<string, any>} SchemaNode - a raw JSON-Schema fragment. Left as an index-typed
|
|
14
|
+
* object rather than a precise union: a schema node's shape genuinely varies (bare `properties`,
|
|
15
|
+
* `$ref`, `anyOf`, `additionalProperties`, `$defs`, …) and this module's whole job is walking
|
|
16
|
+
* between those shapes generically.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {SchemaNode} node
|
|
21
|
+
* @param {SchemaNode} rootSchema
|
|
22
|
+
* @returns {SchemaNode} `node` itself, or the `$defs` target its `$ref` points at.
|
|
23
|
+
*/
|
|
24
|
+
export function deref(node, rootSchema) {
|
|
25
|
+
if (node && typeof node === 'object' && typeof node.$ref === 'string') {
|
|
26
|
+
const parts = node.$ref.replace(/^#\//, '').split('/');
|
|
27
|
+
let target = rootSchema;
|
|
28
|
+
for (const part of parts) {
|
|
29
|
+
target = target?.[part];
|
|
30
|
+
}
|
|
31
|
+
return target;
|
|
32
|
+
}
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolves `segments` starting from `node` (already expected to need dereferencing itself).
|
|
38
|
+
* @param {SchemaNode} node
|
|
39
|
+
* @param {string[]} segments
|
|
40
|
+
* @param {SchemaNode} rootSchema
|
|
41
|
+
* @returns {SchemaNode | undefined}
|
|
42
|
+
*/
|
|
43
|
+
function resolveFrom(node, segments, rootSchema) {
|
|
44
|
+
const current = deref(node, rootSchema);
|
|
45
|
+
if (!current) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (segments.length === 0) {
|
|
49
|
+
return current;
|
|
50
|
+
}
|
|
51
|
+
const [segment, ...rest] = segments;
|
|
52
|
+
|
|
53
|
+
const props = current.properties;
|
|
54
|
+
if (props && Object.prototype.hasOwnProperty.call(props, segment)) {
|
|
55
|
+
return resolveFrom(props[segment], rest, rootSchema);
|
|
56
|
+
}
|
|
57
|
+
if (typeof current.additionalProperties === 'object' && current.additionalProperties !== null) {
|
|
58
|
+
return resolveFrom(current.additionalProperties, rest, rootSchema);
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(current.anyOf)) {
|
|
61
|
+
for (const branch of current.anyOf) {
|
|
62
|
+
const resolved = resolveFrom(branch, segments, rootSchema);
|
|
63
|
+
if (resolved !== undefined) {
|
|
64
|
+
return resolved;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Walks a dotted path (`"levels.L0.model"`, `"review.second_levels.L2.provider"`) through the
|
|
73
|
+
* schema, dereferencing `$ref`s and `anyOf` branches as needed at every step.
|
|
74
|
+
*
|
|
75
|
+
* @param {SchemaNode} rootSchema
|
|
76
|
+
* @param {string} dottedPath - the empty string resolves to `rootSchema` itself (zero segments).
|
|
77
|
+
* @returns {SchemaNode | undefined} the leaf sub-schema, or `undefined` if any segment is missing.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveSchemaPath(rootSchema, dottedPath) {
|
|
80
|
+
// `''.split('.')` is `['']` (one EMPTY-STRING segment), not zero segments — without this guard
|
|
81
|
+
// the empty path would look up `properties['']` and fail, instead of meaning "the root itself".
|
|
82
|
+
const segments = dottedPath === '' ? [] : dottedPath.split('.');
|
|
83
|
+
return resolveFrom(rootSchema, segments, rootSchema);
|
|
84
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret-SHAPED text detection, shared by the validator's `secret-looking-value` rule and by every
|
|
3
|
+
* B1 path that prints text derived from a user's files (`validate.mjs` messages, `load.mjs` parse
|
|
4
|
+
* errors, `refresh.mjs` skip reasons, the `validate`/`models` verbs).
|
|
5
|
+
*
|
|
6
|
+
* This is the SHAPE layer. B0's `redact()` is the other layer: it masks secrets a key resolver
|
|
7
|
+
* has explicitly registered, and deliberately never guesses. Config text is never registered — it
|
|
8
|
+
* is exactly where a user pastes a key by mistake — so B1 masks anything shaped like a vendor
|
|
9
|
+
* secret on top of `redact()`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Conservative, prefix-shaped patterns for common vendor secret formats. Deliberately NOT a
|
|
14
|
+
* generic high-entropy/long-string heuristic: a model id like `claude-haiku-4-5-20251001` or a
|
|
15
|
+
* `keys.jev` reference like `op://vault/item/field` is exactly the kind of long, dash-heavy,
|
|
16
|
+
* mixed-alnum string a naive entropy check would also flag — refusing every valid config forever.
|
|
17
|
+
* Every pattern anchors on a real-world secret PREFIX no legitimate config value shares.
|
|
18
|
+
*
|
|
19
|
+
* NOT `^…$`-anchored: a token embedded in a longer string (a gates argv element
|
|
20
|
+
* `Authorization: Bearer sk-…`) must be caught too. The lookbehind is the word-boundary stand-in —
|
|
21
|
+
* the prefix may not be glued to a preceding identifier character, so `task-…` or `disk-…` never
|
|
22
|
+
* match the `sk-` pattern. No `g`/`y` flag: `.test()` must carry no `lastIndex` state.
|
|
23
|
+
*/
|
|
24
|
+
export const SECRET_LOOKING_PATTERNS = Object.freeze([
|
|
25
|
+
/(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{10,}/, // OpenAI / Anthropic API keys
|
|
26
|
+
/(?<![A-Za-z0-9_-])xai-[A-Za-z0-9]{20,}/, // xAI API keys
|
|
27
|
+
/(?<![A-Za-z0-9_-])xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens
|
|
28
|
+
/(?<![A-Za-z0-9_-])gh[opsu]_[A-Za-z0-9]{20,}/, // GitHub tokens (classic prefixes)
|
|
29
|
+
/(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PATs
|
|
30
|
+
/(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{12,}/, // AWS access key id
|
|
31
|
+
/(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{20,}/, // Google API key
|
|
32
|
+
/(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/, // JWT
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** The mask every B1 scrub writes in place of a secret-shaped token (same text as B0's `redact`). */
|
|
36
|
+
export const SECRET_MASK = '[REDACTED]';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A `keys.<name>` value is a REFERENCE (`env:NAME`, `op://…`, `user`), never a secret, by design.
|
|
40
|
+
* @param {unknown} value
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function isKeyReferenceValue(value) {
|
|
44
|
+
return typeof value === 'string' && (value.startsWith('env:') || value.startsWith('op://') || value === 'user');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {unknown} value
|
|
49
|
+
* @returns {boolean} true when `value` is a string holding at least one secret-shaped token and is
|
|
50
|
+
* not itself a key reference.
|
|
51
|
+
*/
|
|
52
|
+
export function looksLikeSecret(value) {
|
|
53
|
+
if (typeof value !== 'string' || isKeyReferenceValue(value)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return SECRET_LOOKING_PATTERNS.some((pattern) => pattern.test(value));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} text
|
|
61
|
+
* @returns {string[]} every secret-shaped token inside `text` (the matched substrings).
|
|
62
|
+
*/
|
|
63
|
+
export function secretTokensIn(text) {
|
|
64
|
+
const tokens = [];
|
|
65
|
+
for (const pattern of SECRET_LOOKING_PATTERNS) {
|
|
66
|
+
for (const match of text.matchAll(new RegExp(pattern.source, 'g'))) {
|
|
67
|
+
tokens.push(match[0]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return tokens;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Masks every secret-shaped token in `text`. Applied to OUTPUT text (a message, a reason), so no
|
|
75
|
+
* key-reference exemption: a message never needs to show a token in full.
|
|
76
|
+
* @param {string} text
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function maskSecretTokens(text) {
|
|
80
|
+
let out = text;
|
|
81
|
+
for (const pattern of SECRET_LOOKING_PATTERNS) {
|
|
82
|
+
out = out.replace(new RegExp(pattern.source, 'g'), SECRET_MASK);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|