@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,855 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The facts rule (plan §3.8, C16; block B9b): `forge facts` builds the facts sheet a Plan job
|
|
3
|
+
* must read before it designs anything.
|
|
4
|
+
*
|
|
5
|
+
* Step 1 (deterministic): {@link extractClaims} lists every claim token of the brief and the
|
|
6
|
+
* sources it names — CLI flags, `<cli> <verb>` commands (inside code spans), `~/…` and `./…`
|
|
7
|
+
* paths, versions, http(s) endpoints, `@scope/name` packages and environment variables.
|
|
8
|
+
* Step 2: ONE L0 `facts` session (closed-book; Claude gets `--tools "Bash"` with the forbidden list
|
|
9
|
+
* rendered, §5.2) whose packet is the claim list and the rule "run the cheapest read-only check,
|
|
10
|
+
* quote the command and the output line, tag it, never infer". B9b.1: when the caller names the
|
|
11
|
+
* project (`projectDir`), the session runs in a READ-ONLY SNAPSHOT of it ({@link buildSnapshot}:
|
|
12
|
+
* `git archive HEAD` plus the `--sources` inside the project, symlinks and secret-looking files
|
|
13
|
+
* dropped, every file 0444 and directory 0555) so `./…` path claims can be checked; the snapshot
|
|
14
|
+
* lives under the run root — or, when that root itself sits inside a git work tree, under a
|
|
15
|
+
* private B0.1 root in `os.tmpdir()` (`factsTmpRoot`) — and is removed when the verb ends;
|
|
16
|
+
* `spawnSession` refuses a facts cwd below any `.git`. {@link pathViolation} still refuses
|
|
17
|
+
* `~`, `..` and absolute paths outside the tool roots, so the delegate cannot leave it.
|
|
18
|
+
* Step 3 (deterministic): the answer is validated against `schema/facts.schema.json`; a row whose
|
|
19
|
+
* `command` is not an allowed read-only form ({@link readOnlyViolation}'s allow-list; env claims only
|
|
20
|
+
* `printenv NAME >/dev/null`, their excerpt always blanked) or a VERIFIED row with an empty excerpt
|
|
21
|
+
* refuses the whole sheet; every claim, command, excerpt and reason passes through B0 `redact`. The
|
|
22
|
+
* sheet records the brief's sha256 and mtime in its header, both taken from the ONE read of the
|
|
23
|
+
* brief that also feeds the extractor; {@link checkSheetFresh} is what `author` and `plan check` use
|
|
24
|
+
* to refuse a stale one.
|
|
25
|
+
*
|
|
26
|
+
* The sheet carries its rows twice: a table for the reader and a `<!-- facts-json … -->` block
|
|
27
|
+
* that {@link parseSheet} reads back (`plan check` looks claims up there, also when the sheet is
|
|
28
|
+
* pasted verbatim into a plan's §0).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
32
|
+
import { chmodSync, closeSync, constants as FS, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import os from 'node:os';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
37
|
+
import { appendRow } from '../ledger/write.mjs';
|
|
38
|
+
import { exec } from '../util/exec.mjs';
|
|
39
|
+
import { redact } from '../util/redact.mjs';
|
|
40
|
+
import { currentRunRoot } from '../util/tmp.mjs';
|
|
41
|
+
import { factsTmpRoot, gitWorkTreeAncestor, spawnSession } from './spawn.mjs';
|
|
42
|
+
|
|
43
|
+
/** @typedef {'flag'|'command'|'path'|'version'|'endpoint'|'package'|'env'} ClaimKind */
|
|
44
|
+
/** @typedef {{token: string, kind: ClaimKind}} Claim */
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {object} Fact
|
|
47
|
+
* @property {string} fact_id @property {string} claim @property {ClaimKind} kind
|
|
48
|
+
* @property {string} command @property {string} output_excerpt
|
|
49
|
+
* @property {'VERIFIED'|'NOT-FOUND'|'UNVERIFIABLE'} tag @property {string | null} why
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export const FACTS_SCHEMA_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'schema', 'facts.schema.json');
|
|
53
|
+
|
|
54
|
+
/** The delegate's answer schema, as the file states it (with `maxLength`). */
|
|
55
|
+
export const FACTS_SCHEMA = Object.freeze(JSON.parse(readFileSync(FACTS_SCHEMA_PATH, 'utf8')));
|
|
56
|
+
|
|
57
|
+
/** @param {any} node @returns {any} a copy without annotations and string-length keywords. */
|
|
58
|
+
function portable(node) {
|
|
59
|
+
if (Array.isArray(node)) return node.map(portable);
|
|
60
|
+
if (node === null || typeof node !== 'object') return node;
|
|
61
|
+
/** @type {Record<string, any>} */
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const [key, value] of Object.entries(node)) {
|
|
64
|
+
if (['$schema', '$id', 'title', 'description', 'maxLength', 'minLength', 'pattern'].includes(key)) continue;
|
|
65
|
+
out[key] = portable(value);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The schema handed to the provider: string-length and pattern keywords are not portable to every
|
|
72
|
+
* structured-output mode, so the session gets them stripped and step 3 re-checks the file schema.
|
|
73
|
+
*/
|
|
74
|
+
export const FACTS_SESSION_SCHEMA = Object.freeze(portable(FACTS_SCHEMA));
|
|
75
|
+
|
|
76
|
+
const validateFile = new Ajv2020({ allErrors: true, strict: false }).compile(
|
|
77
|
+
Object.fromEntries(Object.entries(FACTS_SCHEMA).filter(([k]) => k !== '$id')),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** Excerpt cap in characters (the schema's `maxLength`). */
|
|
81
|
+
export const EXCERPT_MAX = 200;
|
|
82
|
+
|
|
83
|
+
/** Default `--max-budget-usd` of the facts session (§3.8: "typically under $0.10"). */
|
|
84
|
+
export const FACTS_MAX_BUDGET_USD = 0.5;
|
|
85
|
+
|
|
86
|
+
/** An error raised before or after the session (`code`: `usage`, `refused`, `stale`, `snapshot`). */
|
|
87
|
+
export class FactsError extends Error {
|
|
88
|
+
/** @param {string} code @param {string} message */
|
|
89
|
+
constructor(code, message) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = 'FactsError';
|
|
92
|
+
this.code = code;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------------------------
|
|
97
|
+
// Step 1 — the claim-token extractor
|
|
98
|
+
// ---------------------------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** Kinds in the order they are matched; a span matched by an earlier kind is masked for later ones. */
|
|
101
|
+
const PATTERNS = /** @type {ReadonlyArray<[ClaimKind, RegExp]>} */ ([
|
|
102
|
+
['endpoint', /https?:\/\/[^\s`'"<>)\]|]+/g],
|
|
103
|
+
['package', /(?<![\w./@-])@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*/g],
|
|
104
|
+
['path', /(?<![\w/.~-])(?:~|\.{1,2})\/[^\s`'"<>)\]|,;]*/g],
|
|
105
|
+
['flag', /(?<![\w-])--[a-z][a-z0-9-]*/g],
|
|
106
|
+
['version', /(?<![\w.§])\d+\.\d+(?:\.\d+)*/g],
|
|
107
|
+
['env', /(?<![\w])\$?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?![\w])/g],
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
/** A `<cli> <verb>` pair at the start of a code span (`claude plugin validate .` ⇒ `claude plugin`). */
|
|
111
|
+
const COMMAND_IN_SPAN = /^([a-z][a-z0-9-]*) ([a-z][a-z0-9-]*)(?![\w.\/-])/;
|
|
112
|
+
|
|
113
|
+
/** @param {string} token */
|
|
114
|
+
const trimTail = (token) => token.replace(/[.,:;]+$/, '');
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Every claim token of `text`, deduplicated by (kind, token), in order of first appearance.
|
|
118
|
+
* @param {string} text
|
|
119
|
+
* @returns {Claim[]}
|
|
120
|
+
*/
|
|
121
|
+
export function extractClaims(text) {
|
|
122
|
+
let masked = String(text);
|
|
123
|
+
/** @type {Array<{index: number, token: string, kind: ClaimKind}>} */
|
|
124
|
+
const found = [];
|
|
125
|
+
/** @param {number} start @param {number} length */
|
|
126
|
+
const mask = (start, length) => {
|
|
127
|
+
masked = masked.slice(0, start) + ' '.repeat(length) + masked.slice(start + length);
|
|
128
|
+
};
|
|
129
|
+
/** @param {ClaimKind} kind @param {RegExp} re */
|
|
130
|
+
const scan = (kind, re) => {
|
|
131
|
+
for (const m of [...masked.matchAll(re)]) {
|
|
132
|
+
const token = trimTail(kind === 'env' ? m[0].replace(/^\$/, '') : m[0]);
|
|
133
|
+
if (token.length === 0 || (kind === 'path' && /^(~|\.{1,2})\/$/.test(token))) continue;
|
|
134
|
+
found.push({ index: /** @type {number} */ (m.index), token, kind });
|
|
135
|
+
mask(/** @type {number} */ (m.index), m[0].length);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
scan(...PATTERNS[0]);
|
|
139
|
+
scan(...PATTERNS[1]);
|
|
140
|
+
scan(...PATTERNS[2]);
|
|
141
|
+
for (const m of [...masked.matchAll(/`([^`\n]+)`/g)]) {
|
|
142
|
+
const cmd = COMMAND_IN_SPAN.exec(m[1]);
|
|
143
|
+
if (!cmd) continue;
|
|
144
|
+
const start = /** @type {number} */ (m.index) + 1;
|
|
145
|
+
found.push({ index: start, token: `${cmd[1]} ${cmd[2]}`, kind: 'command' });
|
|
146
|
+
mask(start, cmd[0].length);
|
|
147
|
+
}
|
|
148
|
+
for (const entry of PATTERNS.slice(3)) scan(...entry);
|
|
149
|
+
found.sort((a, b) => a.index - b.index);
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
/** @type {Claim[]} */
|
|
152
|
+
const claims = [];
|
|
153
|
+
for (const { token, kind } of found) {
|
|
154
|
+
const key = `${kind}\0${token}`;
|
|
155
|
+
if (seen.has(key)) continue;
|
|
156
|
+
seen.add(key);
|
|
157
|
+
claims.push({ token, kind });
|
|
158
|
+
}
|
|
159
|
+
return claims;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The text of every source: a file as is, a directory's top-level `.md`/`.txt` files in name order.
|
|
164
|
+
* @param {ReadonlyArray<string>} sources
|
|
165
|
+
* @returns {string[]}
|
|
166
|
+
*/
|
|
167
|
+
function readSourcesText(sources) {
|
|
168
|
+
/** @type {string[]} */
|
|
169
|
+
const parts = [];
|
|
170
|
+
for (const source of sources) {
|
|
171
|
+
const st = statSync(source);
|
|
172
|
+
if (st.isDirectory()) {
|
|
173
|
+
for (const name of readdirSync(source).filter((n) => /\.(md|txt)$/.test(n)).sort()) {
|
|
174
|
+
parts.push(readFileSync(path.join(source, name), 'utf8'));
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
parts.push(readFileSync(source, 'utf8'));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return parts;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The brief's text plus every source it names ({@link readSourcesText}).
|
|
185
|
+
* @param {string} briefPath @param {ReadonlyArray<string>} [sources]
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
export function readClaimText(briefPath, sources = []) {
|
|
189
|
+
return [readFileSync(briefPath, 'utf8'), ...readSourcesText(sources)].join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The brief, read ONCE: its bytes (sha256 and claim text) and its mtime from the same open file.
|
|
194
|
+
* @param {string} briefPath
|
|
195
|
+
* @returns {{bytes: Buffer, mtime: string}}
|
|
196
|
+
*/
|
|
197
|
+
function readBriefOnce(briefPath) {
|
|
198
|
+
const fd = openSync(briefPath, 'r');
|
|
199
|
+
try {
|
|
200
|
+
return { bytes: readFileSync(fd), mtime: fstatSync(fd).mtime.toISOString() };
|
|
201
|
+
} finally {
|
|
202
|
+
closeSync(fd);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------------------------
|
|
207
|
+
// Step 3 — the read-only check and the answer validation
|
|
208
|
+
// ---------------------------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/** Separators between commands: `;`, `&&`, `||`, `|`, `&`, newline, carriage return. */
|
|
211
|
+
const SEPARATORS = new Set([';', '&', '|', '\n', '\r']);
|
|
212
|
+
|
|
213
|
+
/** Characters the shell expands into file names when they are not quoted or escaped. */
|
|
214
|
+
const GLOB_CHARS = new Set(['*', '?', '[', ']', '{', '}']);
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Split a command into segments of words. Quotes group; a backslash escapes the next character.
|
|
218
|
+
* Any `$`, backtick, `<` or `>` outside single quotes — and inside them too, for `<`/`>` — is
|
|
219
|
+
* reported instead of parsed, and so is an unquoted `* ? [ ] { }` (a glob expands AFTER the
|
|
220
|
+
* secret-path check and could name what the check never saw): the allow-list never needs one
|
|
221
|
+
* (fail closed).
|
|
222
|
+
* @param {string} command
|
|
223
|
+
* @returns {{segments: string[][], refused: string | null}}
|
|
224
|
+
*/
|
|
225
|
+
function splitCommand(command) {
|
|
226
|
+
if (/[`]/.test(command)) return { segments: [], refused: 'backticks are not allowed' };
|
|
227
|
+
if (/\$/.test(command)) return { segments: [], refused: '$ expansion is not allowed' };
|
|
228
|
+
if (/[<>]/.test(command)) return { segments: [], refused: 'redirection is not allowed' };
|
|
229
|
+
/** @type {string[][]} */
|
|
230
|
+
const segments = [[]];
|
|
231
|
+
let word = '';
|
|
232
|
+
let inWord = false;
|
|
233
|
+
const flush = () => {
|
|
234
|
+
if (inWord) segments[segments.length - 1].push(word);
|
|
235
|
+
word = '';
|
|
236
|
+
inWord = false;
|
|
237
|
+
};
|
|
238
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
239
|
+
const c = command[i];
|
|
240
|
+
if (c === "'" || c === '"') {
|
|
241
|
+
const end = command.indexOf(c, i + 1);
|
|
242
|
+
if (end < 0) return { segments: [], refused: 'unbalanced quote' };
|
|
243
|
+
word += command.slice(i + 1, end);
|
|
244
|
+
inWord = true;
|
|
245
|
+
i = end;
|
|
246
|
+
} else if (c === '\\') {
|
|
247
|
+
if (i + 1 >= command.length || command[i + 1] === '\n' || command[i + 1] === '\r') return { segments: [], refused: 'line continuation is not allowed' };
|
|
248
|
+
word += command[i + 1];
|
|
249
|
+
inWord = true;
|
|
250
|
+
i += 1;
|
|
251
|
+
} else if (SEPARATORS.has(c)) {
|
|
252
|
+
flush();
|
|
253
|
+
if ((c === '&' || c === '|') && command[i + 1] === c) i += 1;
|
|
254
|
+
segments.push([]);
|
|
255
|
+
} else if (c === ' ' || c === '\t') {
|
|
256
|
+
flush();
|
|
257
|
+
} else {
|
|
258
|
+
if (GLOB_CHARS.has(c)) return { segments: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
|
|
259
|
+
word += c;
|
|
260
|
+
inWord = true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
flush();
|
|
264
|
+
return { segments: segments.filter((s) => s.length > 0), refused: null };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A token that names a secret-looking file (read refused, whatever the command). */
|
|
268
|
+
const SECRET_PATH = [
|
|
269
|
+
/(^|\/)\.env[^/]*$/,
|
|
270
|
+
/(^|\/)\.npmrc$/,
|
|
271
|
+
/(^|\/)\.netrc$/,
|
|
272
|
+
/id_rsa/,
|
|
273
|
+
/id_ed25519/,
|
|
274
|
+
/(^|\/)\.ssh(\/|$)/,
|
|
275
|
+
/(^|\/)\.aws(\/|$)/,
|
|
276
|
+
/(^|\/)\.config\/gh(\/|$)/,
|
|
277
|
+
/\.pem$/,
|
|
278
|
+
/\.key$/,
|
|
279
|
+
/(^|\/)\.code-forge(\/|$)/,
|
|
280
|
+
/(^|\/)\.git-credentials$/,
|
|
281
|
+
/(^|\/)\.docker\/config\.json$/,
|
|
282
|
+
/(^|\/)\.kube\/config$/,
|
|
283
|
+
/(^|\/)\.claude\/\.credentials\.json$/,
|
|
284
|
+
/(^|\/)\.pgpass$/,
|
|
285
|
+
/^\/proc\/[^/]+\/environ$/,
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
/** The only roots an absolute argument may point into (tool installs; never `~`, `/` or the rest). */
|
|
289
|
+
const TOOL_ROOTS = ['/usr', '/opt/homebrew', '/bin'];
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Why one argument reaches outside the delegate's cwd, or null. Relative paths stay inside the
|
|
293
|
+
* cwd (the read-only project snapshot, or an empty directory); `~`, a `..` segment and any absolute path outside {@link TOOL_ROOTS} are refused, so
|
|
294
|
+
* a recursive reader (`rg . ~`, `find / -type f`, `ls -R /etc`) has nowhere to go.
|
|
295
|
+
* @param {string} word
|
|
296
|
+
* @returns {string | null}
|
|
297
|
+
*/
|
|
298
|
+
function pathViolation(word) {
|
|
299
|
+
if (word.startsWith('~')) return 'paths under ~ are not allowed';
|
|
300
|
+
if (/(^|\/)\.\.(\/|$)/.test(word)) return 'paths with a .. segment are not allowed';
|
|
301
|
+
if (word.startsWith('/') && !TOOL_ROOTS.some((root) => word === root || word.startsWith(`${root}/`))) {
|
|
302
|
+
return 'absolute paths outside /usr, /opt/homebrew and /bin are not allowed';
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Commands that read and print, allowed with any in-cwd arguments (after the secret-path check). */
|
|
308
|
+
const PLAIN_READERS = new Set(['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'rg', 'stat', 'file', 'uname', 'sw_vers', 'jq', 'which']);
|
|
309
|
+
|
|
310
|
+
const GIT_READS = new Set(['log', 'show', 'status', 'rev-parse', 'ls-files', 'cat-file', '--version']);
|
|
311
|
+
const NPM_READS = new Set(['view', 'ls', '--version']);
|
|
312
|
+
const HELP_FORMS = new Set(['--help', '-h', '--version']);
|
|
313
|
+
/**
|
|
314
|
+
* find: an ALLOWED predicate list (B9b.1). Any other word starting with `-` (`-exec`, `-execdir`,
|
|
315
|
+
* `-ok`, `-okdir`, `-delete`, `-fls`, `-fprint*`, …) is refused by omission; `!` and bare words are
|
|
316
|
+
* operands (paths, patterns, numbers).
|
|
317
|
+
*/
|
|
318
|
+
export const FIND_PREDICATES = Object.freeze(['-name', '-iname', '-path', '-ipath', '-type', '-maxdepth', '-mindepth', '-print', '-print0', '-newer', '-size', '-empty', '-mtime', '-mmin', '-not', '!', '-a', '-o', '-and', '-or']);
|
|
319
|
+
const FIND_ALLOWED = new Set(FIND_PREDICATES);
|
|
320
|
+
/** The predicates whose next word is their operand (`-mtime -1`, `-size -10k` stay operands). */
|
|
321
|
+
const FIND_TAKES_VALUE = new Set(['-name', '-iname', '-path', '-ipath', '-type', '-maxdepth', '-mindepth', '-newer', '-size', '-mtime', '-mmin']);
|
|
322
|
+
const FIND_MESSAGE = `find: only ${FIND_PREDICATES.join(' ')} and paths`;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param {string[]} rest - the words after `find`.
|
|
326
|
+
* @returns {string | null}
|
|
327
|
+
*/
|
|
328
|
+
function findViolation(rest) {
|
|
329
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
330
|
+
const t = rest[i];
|
|
331
|
+
if (FIND_ALLOWED.has(t)) {
|
|
332
|
+
if (FIND_TAKES_VALUE.has(t)) {
|
|
333
|
+
if (i + 1 >= rest.length) return FIND_MESSAGE;
|
|
334
|
+
i += 1;
|
|
335
|
+
}
|
|
336
|
+
} else if (t.startsWith('-') || ['(', ')', ','].includes(t)) {
|
|
337
|
+
return FIND_MESSAGE;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
/** Build/task runners: their `help`/default targets run user recipes, so the `<cli> --help` case never admits them. */
|
|
343
|
+
const BUILD_RUNNERS = new Set(['make', 'gmake', 'just', 'task', 'rake', 'gradle', 'gradlew', 'mvn', 'mvnw', 'ant', 'ninja', 'cmake', 'bazel', 'bazelisk', 'tox', 'nox', 'invoke', 'inv', 'doit', 'earthly', 'mage', 'npx', 'pnpx', 'bunx']);
|
|
344
|
+
/** curl: the long options allowed; the short ones are any cluster of `s S I L f`; `--max-time <N>` is handled apart. */
|
|
345
|
+
const CURL_LONG = new Set(['--head', '--silent', '--show-error', '--location', '--fail']);
|
|
346
|
+
const CURL_MESSAGE = 'curl: only -s -S -I -L -f --head --silent --show-error --location --fail --max-time <N> and exactly one http(s) URL, as a HEAD request';
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* curl as an option allow-list: every token is an allowed flag, `--max-time <digits>` or the one
|
|
350
|
+
* `http(s)://` URL, and one of the flags asks for HEAD. Anything else (`-o`, `-D`, `-c`, `--trace*`,
|
|
351
|
+
* `--stderr`, `--libcurl`, `--etag-save`, `--hsts`, `--alt-svc`, `-K`, …) is refused by omission.
|
|
352
|
+
* @param {string[]} rest
|
|
353
|
+
* @returns {string | null}
|
|
354
|
+
*/
|
|
355
|
+
function curlViolation(rest) {
|
|
356
|
+
let head = false;
|
|
357
|
+
let urls = 0;
|
|
358
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
359
|
+
const t = rest[i];
|
|
360
|
+
if (/^https?:\/\//.test(t)) {
|
|
361
|
+
urls += 1;
|
|
362
|
+
} else if (/^-[sSILf]+$/.test(t)) {
|
|
363
|
+
if (t.includes('I')) head = true;
|
|
364
|
+
} else if (CURL_LONG.has(t)) {
|
|
365
|
+
if (t === '--head') head = true;
|
|
366
|
+
} else if (t === '--max-time' && /^\d+$/.test(rest[i + 1] ?? '')) {
|
|
367
|
+
i += 1;
|
|
368
|
+
} else {
|
|
369
|
+
return CURL_MESSAGE;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return head && urls === 1 ? null : CURL_MESSAGE;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Why one segment is not an allowed read-only form, or null.
|
|
377
|
+
* @param {string[]} argv
|
|
378
|
+
* @returns {string | null}
|
|
379
|
+
*/
|
|
380
|
+
function segmentViolation(argv) {
|
|
381
|
+
if (argv.some((t) => SECRET_PATH.some((re) => re.test(t)))) return 'reads a secret-looking file';
|
|
382
|
+
const [verb, ...rest] = argv;
|
|
383
|
+
if (verb.includes('/')) return 'a verb may not be a path';
|
|
384
|
+
if (!/^[A-Za-z0-9._-]+$/.test(verb)) return `not an allowed read-only form: "${verb}"`;
|
|
385
|
+
for (const word of rest) {
|
|
386
|
+
const why = pathViolation(word);
|
|
387
|
+
if (why) return why;
|
|
388
|
+
}
|
|
389
|
+
if (PLAIN_READERS.has(verb)) {
|
|
390
|
+
if (verb === 'rg' && rest.some((t) => t.startsWith('--pre'))) return 'rg --pre runs a command';
|
|
391
|
+
if (verb === 'grep' && rest.some((t) => /^-[a-zA-Z]*[rR]/.test(t) || t === '--recursive' || t === '--dereference-recursive')) return 'grep: -r, -R and --recursive are not allowed';
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
switch (verb) {
|
|
395
|
+
case 'command':
|
|
396
|
+
return rest.length === 2 && rest[0] === '-v' ? null : 'command: only command -v <name>';
|
|
397
|
+
case 'find':
|
|
398
|
+
return findViolation(rest);
|
|
399
|
+
case 'git':
|
|
400
|
+
if (rest.length === 0 || !GIT_READS.has(rest[0])) return 'git: only log, show, status, rev-parse, ls-files, cat-file or --version, with no global option';
|
|
401
|
+
return rest.some((t) => /^--(output|ext-diff|textconv)/.test(t)) ? 'git: --output, --ext-diff and --textconv are not allowed' : null;
|
|
402
|
+
case 'npm':
|
|
403
|
+
return rest.length > 0 && NPM_READS.has(rest[0]) ? null : 'npm: only view, ls or --version';
|
|
404
|
+
case 'node':
|
|
405
|
+
return rest.length === 1 && (rest[0] === '--version' || rest[0] === '-v') ? null : 'node: only --version';
|
|
406
|
+
case 'test':
|
|
407
|
+
return rest.length === 2 && ['-e', '-f', '-d', '-n'].includes(rest[0]) ? null : 'test: only -e, -f, -d or -n with one operand';
|
|
408
|
+
case 'curl':
|
|
409
|
+
return curlViolation(rest);
|
|
410
|
+
default:
|
|
411
|
+
// `<cli> --help|-h|--version` — exactly two tokens, a bare CLI name (never a path), never a build runner
|
|
412
|
+
if (BUILD_RUNNERS.has(verb)) return `${verb}: build and task runners are not allowed`;
|
|
413
|
+
return rest.length === 1 && HELP_FORMS.has(rest[0]) && /^[a-z0-9][a-z0-9._-]*$/.test(verb) ? null : `not an allowed read-only form: "${verb}"`;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Why a delegate's `command` is not an allowed read-only command, or null when it is. An
|
|
419
|
+
* ALLOW-LIST (fail closed): every segment between `;`, `&&`, `||`, `|`, `&`, newline or CR must
|
|
420
|
+
* be one of the forms in {@link segmentViolation}; `$`, backticks and redirections are refused.
|
|
421
|
+
* @param {string} command
|
|
422
|
+
* @returns {string | null}
|
|
423
|
+
*/
|
|
424
|
+
export function readOnlyViolation(command) {
|
|
425
|
+
const { segments, refused } = splitCommand(command);
|
|
426
|
+
if (refused) return refused;
|
|
427
|
+
if (segments.length === 0) return 'empty command';
|
|
428
|
+
for (const argv of segments) {
|
|
429
|
+
const why = segmentViolation(argv);
|
|
430
|
+
if (why) return why;
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The one form an env claim may be checked with: existence only, the value never printed. */
|
|
436
|
+
const ENV_CHECK = /^printenv ([A-Z][A-Z0-9_]*) >\/dev\/null$/;
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Validate the delegate's answer (step 3). Claims the delegate left out are kept as UNVERIFIABLE
|
|
440
|
+
* rows ("no answer") — a missing answer is never read as a fact.
|
|
441
|
+
* @param {unknown} answer @param {ReadonlyArray<Claim>} claims
|
|
442
|
+
* @returns {Fact[]} the rows, in claim order.
|
|
443
|
+
* @throws {FactsError} `refused` naming each fact id and reason (never the output excerpt).
|
|
444
|
+
*/
|
|
445
|
+
export function validateFacts(answer, claims) {
|
|
446
|
+
if (!validateFile(answer)) {
|
|
447
|
+
const where = (validateFile.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).slice(0, 5);
|
|
448
|
+
throw new FactsError('refused', `facts answer does not match facts.schema.json: ${where.join('; ')}`);
|
|
449
|
+
}
|
|
450
|
+
const rows = /** @type {{facts: Fact[]}} */ (answer).facts;
|
|
451
|
+
const problems = [];
|
|
452
|
+
for (const row of rows) {
|
|
453
|
+
if (row.tag === 'VERIFIED' && row.output_excerpt.trim().length === 0) problems.push(`${row.fact_id}: VERIFIED with an empty output excerpt`);
|
|
454
|
+
if (row.command.trim().length === 0) {
|
|
455
|
+
if (row.tag !== 'UNVERIFIABLE') problems.push(`${row.fact_id}: ${row.tag} without a command`);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (row.kind === 'env') {
|
|
459
|
+
const env = ENV_CHECK.exec(row.command);
|
|
460
|
+
if (!env || env[1] !== row.claim) problems.push(`${row.fact_id}: an env claim may only be checked with printenv ${row.claim} >/dev/null`);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const why = readOnlyViolation(row.command);
|
|
464
|
+
if (why) problems.push(`${row.fact_id}: command is not read-only: ${why}`);
|
|
465
|
+
}
|
|
466
|
+
if (problems.length > 0) throw new FactsError('refused', `facts sheet refused: ${problems.join('; ')}`);
|
|
467
|
+
/** @type {Fact[]} */
|
|
468
|
+
const out = [];
|
|
469
|
+
claims.forEach((claim, i) => {
|
|
470
|
+
const row = rows.find((r) => r.claim === claim.token && r.kind === claim.kind);
|
|
471
|
+
out.push(
|
|
472
|
+
row
|
|
473
|
+
? { ...row, fact_id: `F${i + 1}`, claim: redact(row.claim), command: redact(row.command), output_excerpt: row.kind === 'env' ? '' : redact(row.output_excerpt), why: row.why === null ? null : redact(row.why) }
|
|
474
|
+
: { fact_id: `F${i + 1}`, claim: redact(claim.token), kind: claim.kind, command: '', output_excerpt: '', tag: 'UNVERIFIABLE', why: 'no answer from the delegate' },
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------------------------
|
|
481
|
+
// The sheet
|
|
482
|
+
// ---------------------------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
/** @param {string | Buffer} bytes @returns {string} */
|
|
485
|
+
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
486
|
+
|
|
487
|
+
/** @param {string} text */
|
|
488
|
+
const cell = (text) => String(text).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
|
|
489
|
+
|
|
490
|
+
/** @param {string} text @returns {string} a code span that survives backticks inside. */
|
|
491
|
+
const code = (text) => (text.length === 0 ? '—' : text.includes('`') ? `\`\` ${text} \`\`` : `\`${text}\``);
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* @param {{briefRef: string, briefSha: string, briefMtime: string, builtAt: string, facts: ReadonlyArray<Fact>}} sheet
|
|
495
|
+
* @returns {string} the facts sheet markdown.
|
|
496
|
+
*/
|
|
497
|
+
export function renderSheet({ briefRef, briefSha, briefMtime, builtAt, facts }) {
|
|
498
|
+
const count = (/** @type {string} */ tag) => facts.filter((f) => f.tag === tag).length;
|
|
499
|
+
const lines = [
|
|
500
|
+
'# Facts sheet',
|
|
501
|
+
'',
|
|
502
|
+
`- brief: \`${briefRef}\``,
|
|
503
|
+
`- brief_sha256: \`${briefSha}\``,
|
|
504
|
+
`- brief_mtime: \`${briefMtime}\``,
|
|
505
|
+
`- built_at: \`${builtAt}\``,
|
|
506
|
+
`- claims: ${facts.length} (VERIFIED ${count('VERIFIED')} · NOT-FOUND ${count('NOT-FOUND')} · UNVERIFIABLE ${count('UNVERIFIABLE')})`,
|
|
507
|
+
'',
|
|
508
|
+
'| fact_id | tag | kind | claim | command | output | why |',
|
|
509
|
+
'|---|---|---|---|---|---|---|',
|
|
510
|
+
...facts.map((f) => `| ${f.fact_id} | ${f.tag} | ${f.kind} | ${cell(code(f.claim))} | ${cell(code(f.command))} | ${cell(f.output_excerpt || '—')} | ${cell(f.why ?? '—')} |`),
|
|
511
|
+
'',
|
|
512
|
+
'<!-- facts-json',
|
|
513
|
+
`[\n${facts.map((f) => JSON.stringify(f)).join(',\n')}\n]`.replace(/</g, '\\u003c').replace(/>/g, '\\u003e'),
|
|
514
|
+
'-->',
|
|
515
|
+
'',
|
|
516
|
+
];
|
|
517
|
+
return lines.join('\n');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Read a sheet (or a plan whose §0 is the sheet, verbatim) back.
|
|
522
|
+
* @param {string} text
|
|
523
|
+
* @returns {{briefRef: string, briefSha: string, briefMtime: string, facts: Fact[]} | null} null when no sheet is there.
|
|
524
|
+
*/
|
|
525
|
+
export function parseSheet(text) {
|
|
526
|
+
const sha = /^- brief_sha256: `([0-9a-f]{64})`$/m.exec(text);
|
|
527
|
+
const ref = /^- brief: `([^`]+)`$/m.exec(text);
|
|
528
|
+
const mtime = /^- brief_mtime: `([^`]+)`$/m.exec(text);
|
|
529
|
+
const json = /^<!-- facts-json\n([\s\S]*?)\n-->$/m.exec(text);
|
|
530
|
+
if (!sha || !ref || !json) return null;
|
|
531
|
+
let facts;
|
|
532
|
+
try {
|
|
533
|
+
facts = JSON.parse(json[1]);
|
|
534
|
+
} catch {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
if (!Array.isArray(facts)) return null;
|
|
538
|
+
return { briefRef: ref[1], briefSha: sha[1], briefMtime: mtime ? mtime[1] : '', facts };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Is the sheet still about this brief? Stale ⇔ the brief's current sha256 differs from the one the
|
|
543
|
+
* sheet recorded (a touched-but-unchanged brief is not stale; an edited one is).
|
|
544
|
+
* @param {string} sheetText @param {string} briefPath
|
|
545
|
+
* @returns {{ok: true, sheet: NonNullable<ReturnType<typeof parseSheet>>} | {ok: false, code: 'bad-sheet' | 'stale', message: string}}
|
|
546
|
+
*/
|
|
547
|
+
export function checkSheetFresh(sheetText, briefPath) {
|
|
548
|
+
const sheet = parseSheet(sheetText);
|
|
549
|
+
if (!sheet) return { ok: false, code: 'bad-sheet', message: 'facts sheet is not a facts sheet: run forge facts first' };
|
|
550
|
+
if (sha256(readFileSync(briefPath)) !== sheet.briefSha) {
|
|
551
|
+
return { ok: false, code: 'stale', message: 'facts sheet is stale: brief changed after it was built' };
|
|
552
|
+
}
|
|
553
|
+
return { ok: true, sheet };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ---------------------------------------------------------------------------------------------
|
|
557
|
+
// Step 2 + the whole verb
|
|
558
|
+
// ---------------------------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
const RULE = [
|
|
561
|
+
'# Facts delegate',
|
|
562
|
+
'',
|
|
563
|
+
'For each claim below, run the cheapest READ-ONLY check (`<cli> --help | grep -c <flag>`, `which <cli>`, `ls <path>`,',
|
|
564
|
+
'`<cli> --version`, `npm view <name> version`). Quote the exact command and the output line it printed (at most 200',
|
|
565
|
+
'characters). Tag each claim VERIFIED (the output shows it), NOT-FOUND (the check ran and it is absent) or',
|
|
566
|
+
'UNVERIFIABLE (no read-only check can decide it — say why in `why`). Never infer, never guess. Only these forms are',
|
|
567
|
+
'accepted, joined by `|` if needed: which, command -v, ls, cat, head, tail, wc, grep (no -r), rg,',
|
|
568
|
+
'find (only -name -iname -path -ipath -type -maxdepth -mindepth -print -print0 -newer -size -empty -mtime -mmin',
|
|
569
|
+
'-not ! -a -o -and -or, plus paths), stat, file, uname, sw_vers, jq, `<cli> --help|-h|--version` (a bare CLI name, no path, no build runner), git',
|
|
570
|
+
'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, node --version, test -e|-f|-d|-n, `curl -sI <one URL>`.',
|
|
571
|
+
'No `$`, no backticks, no redirection, no unquoted glob, no `~`, no absolute path outside /usr, /opt/homebrew or',
|
|
572
|
+
'/bin, no secret files.',
|
|
573
|
+
'An env claim is checked ONLY with `printenv NAME >/dev/null` (quote its exit status; the value is never shown).',
|
|
574
|
+
'',
|
|
575
|
+
'Reply with ONE JSON object: {"facts": [{"fact_id", "claim", "kind", "command", "output_excerpt", "tag", "why"}]} —',
|
|
576
|
+
'one row per claim, `fact_id`, `claim` and `kind` copied from the list, `why` null unless UNVERIFIABLE.',
|
|
577
|
+
'',
|
|
578
|
+
'## Claims',
|
|
579
|
+
'',
|
|
580
|
+
].join('\n');
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* @param {ReadonlyArray<Claim>} claims
|
|
584
|
+
* @returns {string} the delegate's packet.
|
|
585
|
+
*/
|
|
586
|
+
export function buildFactsPacket(claims) {
|
|
587
|
+
return `${RULE}${claims.map((c, i) => `- F${i + 1} ${c.kind}: ${c.token}`).join('\n')}\n`;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* @typedef {object} BuildFactsOpts
|
|
592
|
+
* @property {Record<string, any>} cfg
|
|
593
|
+
* @property {string} briefPath - absolute.
|
|
594
|
+
* @property {string[]} [sources]
|
|
595
|
+
* @property {string} outPath - absolute path of the sheet to write.
|
|
596
|
+
* @property {string} [runRoot] @property {string} [run] @property {string} [slug]
|
|
597
|
+
* @property {number} [maxBudgetUsd] @property {number} [timeoutMs]
|
|
598
|
+
* @property {string} [projectDir] - the project (the verb's cwd): the delegate runs in a read-only
|
|
599
|
+
* snapshot of its git HEAD plus the in-project `sources`; absent ⇒ an empty cwd.
|
|
600
|
+
* @property {() => Date} [now]
|
|
601
|
+
*/
|
|
602
|
+
|
|
603
|
+
// ---------------------------------------------------------------------------------------------
|
|
604
|
+
// B9b.1 — the delegate's read-only project snapshot
|
|
605
|
+
// ---------------------------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
const GIT_TIMEOUT_MS = 120000;
|
|
608
|
+
|
|
609
|
+
/** `process.env` minus every inherited `GIT_*`, with no system/global git config. */
|
|
610
|
+
function gitEnv() {
|
|
611
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
612
|
+
const env = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
|
|
613
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
614
|
+
if (!key.startsWith('GIT_')) env[key] = value;
|
|
615
|
+
}
|
|
616
|
+
return env;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Drop symlinks and secret-looking entries, then make every file 0444 and every directory 0555
|
|
621
|
+
* (children first). A symlink could point anywhere outside the snapshot; a secret file is never
|
|
622
|
+
* handed to the delegate even though its reads are refused by name.
|
|
623
|
+
* @param {string} dir @param {string} [rel] - `dir` relative to the snapshot root.
|
|
624
|
+
*/
|
|
625
|
+
function sealTree(dir, rel = '') {
|
|
626
|
+
for (const name of readdirSync(dir)) {
|
|
627
|
+
const full = path.join(dir, name);
|
|
628
|
+
const relPath = rel ? `${rel}/${name}` : name;
|
|
629
|
+
const st = lstatSync(full);
|
|
630
|
+
if (st.isSymbolicLink() || SECRET_PATH.some((re) => re.test(relPath))) {
|
|
631
|
+
rmSync(full, { recursive: true, force: true });
|
|
632
|
+
} else if (st.isDirectory()) {
|
|
633
|
+
sealTree(full, relPath);
|
|
634
|
+
} else if (st.isFile()) {
|
|
635
|
+
chmodSync(full, 0o444);
|
|
636
|
+
} else {
|
|
637
|
+
unlinkSync(full); // fifos, sockets, devices: never part of a read-only view
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
chmodSync(dir, 0o555);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** @param {string} p @returns {string} `p` realpath'd when it exists, else resolved as given. */
|
|
644
|
+
function realOrResolved(p) {
|
|
645
|
+
try {
|
|
646
|
+
return realpathSync(p);
|
|
647
|
+
} catch {
|
|
648
|
+
return path.resolve(p);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** @param {string} dir - make every directory under `dir` writable again, then remove it. */
|
|
653
|
+
export function removeSnapshot(dir) {
|
|
654
|
+
/** @param {string} d */
|
|
655
|
+
const unseal = (d) => {
|
|
656
|
+
chmodSync(d, 0o700);
|
|
657
|
+
for (const name of readdirSync(d)) {
|
|
658
|
+
const full = path.join(d, name);
|
|
659
|
+
if (lstatSync(full).isDirectory()) unseal(full);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
try {
|
|
663
|
+
unseal(dir);
|
|
664
|
+
} catch (err) {
|
|
665
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'ENOENT') throw err;
|
|
666
|
+
}
|
|
667
|
+
rmSync(dir, { recursive: true, force: true });
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** @param {string} p @returns {import('node:fs').Stats | null} lstat of `p`, or null when absent. */
|
|
671
|
+
function lstatOrNull(p) {
|
|
672
|
+
try {
|
|
673
|
+
return lstatSync(p);
|
|
674
|
+
} catch {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Remove every symlink and special entry below `dir` (regular files and directories stay). Runs
|
|
681
|
+
* right after `tar -x`, BEFORE any source is copied in: a symlink tracked at HEAD (`foo -> ../..`)
|
|
682
|
+
* that the working tree has replaced with a real directory would otherwise turn a source copy
|
|
683
|
+
* into a write outside the snapshot.
|
|
684
|
+
* @param {string} dir
|
|
685
|
+
*/
|
|
686
|
+
function dropLinks(dir) {
|
|
687
|
+
for (const name of readdirSync(dir)) {
|
|
688
|
+
const full = path.join(dir, name);
|
|
689
|
+
const st = lstatSync(full);
|
|
690
|
+
if (st.isDirectory()) dropLinks(full);
|
|
691
|
+
else if (!st.isFile()) unlinkSync(full); // a symlink (to anything), fifo, socket or device
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Refuse a path below `snapDir` that crosses a symlink: every segment from `snapDir` down to `dir`
|
|
697
|
+
* (inclusive) is lstat'd; a missing segment ends the walk (the copy creates it next).
|
|
698
|
+
* @param {string} snapDir @param {string} dir
|
|
699
|
+
*/
|
|
700
|
+
function assertNoLinkSegments(snapDir, dir) {
|
|
701
|
+
const rel = path.relative(snapDir, dir);
|
|
702
|
+
let cur = snapDir;
|
|
703
|
+
for (const seg of rel === '' ? [] : rel.split(path.sep)) {
|
|
704
|
+
cur = path.join(cur, seg);
|
|
705
|
+
const st = lstatOrNull(cur);
|
|
706
|
+
if (st === null) return;
|
|
707
|
+
if (st.isSymbolicLink()) throw new FactsError('snapshot', `the project snapshot refused a copy across a symlink (${path.relative(snapDir, cur)})`);
|
|
708
|
+
if (!st.isDirectory()) throw new FactsError('snapshot', `the project snapshot has a file where a directory is needed (${path.relative(snapDir, cur)})`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Copy `src` (a working-tree file or directory) to `dst` inside `snapDir` without following any
|
|
714
|
+
* symlink: entries are lstat'd (symlinks, `.git` and secret-looking paths dropped), parents are
|
|
715
|
+
* created here and checked segment by segment, and every file is created with
|
|
716
|
+
* `O_EXCL | O_NOFOLLOW` — a HEAD file of the same name is unlinked first, never written through.
|
|
717
|
+
* @param {string} src @param {string} dst @param {string} snapDir
|
|
718
|
+
*/
|
|
719
|
+
function copyNoFollow(src, dst, snapDir) {
|
|
720
|
+
const rel = path.relative(snapDir, dst).split(path.sep).join('/');
|
|
721
|
+
const st = lstatSync(src);
|
|
722
|
+
if (st.isSymbolicLink() || /(^|\/)\.git(\/|$)/.test(rel) || SECRET_PATH.some((re) => re.test(rel))) return;
|
|
723
|
+
assertNoLinkSegments(snapDir, path.dirname(dst));
|
|
724
|
+
mkdirSync(path.dirname(dst), { recursive: true, mode: 0o700 }); // only segments the check found missing
|
|
725
|
+
const at = lstatOrNull(dst);
|
|
726
|
+
if (st.isDirectory()) {
|
|
727
|
+
if (at === null) mkdirSync(dst, { mode: 0o700 });
|
|
728
|
+
else if (!at.isDirectory()) throw new FactsError('snapshot', `the project snapshot has a file where a directory is needed (${rel})`);
|
|
729
|
+
for (const name of readdirSync(src)) copyNoFollow(path.join(src, name), path.join(dst, name), snapDir);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (!st.isFile()) return;
|
|
733
|
+
if (at !== null) {
|
|
734
|
+
if (!at.isFile()) throw new FactsError('snapshot', `the project snapshot has a directory where a file is needed (${rel})`);
|
|
735
|
+
unlinkSync(dst); // the HEAD copy: the source's current content replaces it
|
|
736
|
+
}
|
|
737
|
+
const fd = openSync(dst, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | FS.O_NOFOLLOW, 0o600);
|
|
738
|
+
try {
|
|
739
|
+
writeFileSync(fd, readFileSync(src));
|
|
740
|
+
} finally {
|
|
741
|
+
closeSync(fd);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Build the facts delegate's read-only view of the project at `snapDir`: the tracked tree at HEAD
|
|
747
|
+
* (`git archive` + `tar -x`, binary-safe, the real index and tree never touched; every symlink and
|
|
748
|
+
* special entry dropped right after extraction) plus every `source` inside the project at its
|
|
749
|
+
* current content ({@link copyNoFollow}; untracked sources included; a source outside `projectDir`
|
|
750
|
+
* — a sibling directory of the repo included — is left out: it could only be reached through
|
|
751
|
+
* `..`). Not a git repo, or no commit yet (`git rev-parse --verify HEAD` fails) ⇒ the sources
|
|
752
|
+
* alone; HEAD resolves but the archive or the extraction fails ⇒ `FactsError('snapshot')`. The
|
|
753
|
+
* tar file never outlives the call.
|
|
754
|
+
* @param {string} projectDir @param {ReadonlyArray<string>} sources - absolute.
|
|
755
|
+
* @param {string} snapDir - created here. @param {string} workDir - scratch for the tar file.
|
|
756
|
+
* @param {typeof exec} [run] - the git/tar runner (tests inject a failing one).
|
|
757
|
+
* @returns {Promise<string>} the session cwd: `snapDir` joined with the project's path below the git
|
|
758
|
+
* top level (the project itself when it is a subdirectory of the repo).
|
|
759
|
+
*/
|
|
760
|
+
export async function buildSnapshot(projectDir, sources, snapDir, workDir, run = exec) {
|
|
761
|
+
mkdirSync(snapDir, { recursive: true, mode: 0o700 });
|
|
762
|
+
const project = realpathSync(projectDir);
|
|
763
|
+
let root = project;
|
|
764
|
+
const top = await run(['git', 'rev-parse', '--show-toplevel'], { cwd: project, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
765
|
+
if (top.result === 'ok' && top.stdout.trim().length > 0) {
|
|
766
|
+
root = realpathSync(top.stdout.trim());
|
|
767
|
+
const head = await run(['git', 'rev-parse', '--verify', '-q', 'HEAD'], { cwd: root, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
768
|
+
if (head.result === 'ok' && head.stdout.trim().length > 0) {
|
|
769
|
+
const tarPath = path.join(workDir, 'head.tar');
|
|
770
|
+
try {
|
|
771
|
+
const archived = await run(['git', 'archive', '--format=tar', '-o', tarPath, 'HEAD'], { cwd: root, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
772
|
+
if (archived.result !== 'ok') throw new FactsError('snapshot', `the project snapshot could not be archived (git archive exit ${archived.code})`);
|
|
773
|
+
const untar = await run(['tar', '-xf', tarPath, '-C', snapDir], { timeoutMs: GIT_TIMEOUT_MS });
|
|
774
|
+
if (untar.result !== 'ok') throw new FactsError('snapshot', `the project snapshot could not be extracted (tar exit ${untar.code})`);
|
|
775
|
+
} finally {
|
|
776
|
+
rmSync(tarPath, { force: true });
|
|
777
|
+
}
|
|
778
|
+
dropLinks(snapDir);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// the project may be a subdirectory of the git top level: `./x` claims resolve against IT
|
|
782
|
+
const sub = path.relative(root, project);
|
|
783
|
+
const sessionCwd = path.join(snapDir, sub);
|
|
784
|
+
assertNoLinkSegments(snapDir, sessionCwd);
|
|
785
|
+
mkdirSync(sessionCwd, { recursive: true, mode: 0o700 });
|
|
786
|
+
for (const source of sources) {
|
|
787
|
+
const real = realpathSync(source);
|
|
788
|
+
const inProject = path.relative(project, real);
|
|
789
|
+
if (inProject === '' || inProject.startsWith('..') || path.isAbsolute(inProject)) continue;
|
|
790
|
+
copyNoFollow(real, path.join(sessionCwd, inProject), snapDir);
|
|
791
|
+
}
|
|
792
|
+
sealTree(snapDir);
|
|
793
|
+
return sessionCwd;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Build the facts sheet (steps 1–3) and write it to `outPath`.
|
|
798
|
+
* @param {BuildFactsOpts} opts
|
|
799
|
+
* @param {import('./spawn.mjs').SessionDeps} [deps]
|
|
800
|
+
* @returns {Promise<{status: string, reason?: string | null, outPath?: string, claims: Claim[], facts?: Fact[]}>}
|
|
801
|
+
*/
|
|
802
|
+
export async function buildFacts(opts, deps = {}) {
|
|
803
|
+
const brief = readBriefOnce(opts.briefPath);
|
|
804
|
+
const claims = extractClaims([brief.bytes.toString('utf8'), ...readSourcesText(opts.sources ?? [])].join('\n'));
|
|
805
|
+
const root = opts.runRoot ?? currentRunRoot();
|
|
806
|
+
// B9b.1: a run root inside a git work tree (`tmp.root` configured in the project) cannot hold
|
|
807
|
+
// the snapshot — git and the harness would walk up into the live repo — so the facts area moves
|
|
808
|
+
// to a private B0.1 root under os.tmpdir(); `spawnSession` refuses the cwd either way otherwise.
|
|
809
|
+
const area = gitWorkTreeAncestor(realOrResolved(root)) === null ? path.join(root, 'facts') : path.join(factsTmpRoot(root, { create: true }), 'facts');
|
|
810
|
+
const dir = path.join(area, `${Date.now()}-${randomBytes(4).toString('hex')}`);
|
|
811
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
812
|
+
const packetPath = path.join(dir, 'packet.md');
|
|
813
|
+
const snapDir = path.join(dir, 'snapshot');
|
|
814
|
+
try {
|
|
815
|
+
writeFileSync(packetPath, buildFactsPacket(claims), { mode: 0o600 });
|
|
816
|
+
const cwd = opts.projectDir === undefined ? undefined : await buildSnapshot(opts.projectDir, opts.sources ?? [], snapDir, dir);
|
|
817
|
+
const result = await spawnSession(
|
|
818
|
+
{
|
|
819
|
+
cfg: opts.cfg,
|
|
820
|
+
level: 'L0',
|
|
821
|
+
role: 'facts',
|
|
822
|
+
promptPath: packetPath,
|
|
823
|
+
schema: FACTS_SESSION_SCHEMA,
|
|
824
|
+
maxBudgetUsd: opts.maxBudgetUsd ?? FACTS_MAX_BUDGET_USD,
|
|
825
|
+
runRoot: root,
|
|
826
|
+
run: opts.run,
|
|
827
|
+
slug: opts.slug,
|
|
828
|
+
timeoutMs: opts.timeoutMs,
|
|
829
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
830
|
+
},
|
|
831
|
+
deps,
|
|
832
|
+
);
|
|
833
|
+
if (result.status !== 'ok') return { status: result.status, reason: result.reason ?? null, claims };
|
|
834
|
+
const facts = validateFacts(result.answer, claims);
|
|
835
|
+
const now = (opts.now ?? (() => new Date()))();
|
|
836
|
+
const rel = path.relative(path.dirname(opts.outPath), opts.briefPath).split(path.sep).join('/');
|
|
837
|
+
const text = renderSheet({
|
|
838
|
+
briefRef: rel,
|
|
839
|
+
briefSha: sha256(brief.bytes),
|
|
840
|
+
briefMtime: brief.mtime,
|
|
841
|
+
builtAt: now.toISOString(),
|
|
842
|
+
facts,
|
|
843
|
+
});
|
|
844
|
+
mkdirSync(path.dirname(opts.outPath), { recursive: true });
|
|
845
|
+
writeFileSync(opts.outPath, text);
|
|
846
|
+
const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
|
|
847
|
+
if (writeRow) {
|
|
848
|
+
await writeRow({ event: 'facts.built', role: 'facts', run: opts.run ?? null, claims: claims.length, verified: facts.filter((f) => f.tag === 'VERIFIED').length, tokens_in: result.row?.tokens_in ?? null, tokens_out: result.row?.tokens_out ?? null });
|
|
849
|
+
}
|
|
850
|
+
return { status: 'ok', outPath: opts.outPath, claims, facts };
|
|
851
|
+
} finally {
|
|
852
|
+
removeSnapshot(snapDir);
|
|
853
|
+
rmSync(dir, { recursive: true, force: true });
|
|
854
|
+
}
|
|
855
|
+
}
|