mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 10:40:32 +01:00
WIP GST-850: allowlist docs paths, security and money classes (pins not yet repinned; review pending)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
35a676ff68
commit
a9fe957266
12 files changed
+725
-31
No files matched your search
@@ -0,0 +1,5 @@
|
||||
- **A write-up with one clean chart now gets one reader, and a translation fix gets the docs tier** (GST-850). `lib/review-allowlist.ts` adds two allowlisted docs paths to `/ship`'s review depth: a new SVG whose whole file is built from presentational elements and attributes (no prefixes, handlers, bindings, entities, doctype, scripts or outside references), and a locale resource edit (JSON, ARB, YAML, PO, `.strings`, XLIFF, Fluent or `.properties` under a locale directory) that changes only literal string values with the same keys and placeholders. Both read the whole input in linear time and keep anything they cannot parse as code. Every bypass the R51 review rounds found (string-concat HTML, i18n `.ts`, edits inside `<script>`, multi-line SVG attributes, namespaced `<s:script>`, Vue and Alpine bindings, the backtracking input) is a test row that stays code.
|
||||
- **The security class reaches outbound URLs, archives and every `child_process` spelling.** `lib/review-risk.ts` now flags `fetch`, `http(s).request`, axios, got, undici, `requests` and `urlopen` on non-literal URLs, `new URL` from input, redirect toggles, proxy settings and host allowlists; tar, zip and unzip extraction, `path.join` or `resolve` on request or entry input and `'..'` checks; and Node `exec` through named, default, namespace, split-line, `require` and dynamic imports.
|
||||
- **The money class reads metering and charge code.** Paths and lines naming meter, metering, metered, charge, billable, billed or credits, plus token usage, a billing proxy and cost or charge clients, now route to the money reader. gbrain-evals #289, which changed a metering proxy and billing clients and read `required=none`, is a regression test that reads money. "costume", a CLI's usage text and a plain proxy do not.
|
||||
- **When a diff publishes numbers, the batched specialist reads the write-up's text files** instead of leaving docs out of its diff.
|
||||
- Replayed on the 26 GSTA-167 no-loss units, no unit loses a reviewer role, so no saved blocker can be lost. Over the last 100 main commits, 2 move from light to full and no commit with a gate or risk class drops depth.
|
||||
+47
-8
@@ -181,7 +181,8 @@ follows that file, four files deep. Precedence: agent prose (`.gstack/**`), a te
|
||||
fixture path, a gate path, a config file (`.github/**`, anything under a `config/`
|
||||
directory, dotfiles, `*.config.*`, `package.json`, lockfiles, `tsconfig*.json`,
|
||||
`manifest.json` and the other tool manifests `lib/review-depth.ts` lists) and a named
|
||||
consumer each keep the path code, and YAML, TOML and SVG are always code. More than
|
||||
consumer each keep the path code, and YAML, TOML and SVG are never data (an SVG can
|
||||
still be an [allowlisted docs path](#allowlisted-docs-paths)). More than
|
||||
2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed`
|
||||
and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
|
||||
finds the consumers, `gstack-ship-preflight`'s `RUNTIME_LINES` reads the same
|
||||
@@ -201,17 +202,55 @@ in a data file still schedules the outside voice.
|
||||
added and deleted lines; any class makes the depth full (`risk:<class>` in
|
||||
`reasons=`, `review_depth.risk` in the receipt). money, auth and secret are the
|
||||
[targeted pass](#targeted-pass-on-money-gate-auth-and-secret-code)'s matchers.
|
||||
**security** is a trust-boundary path (trust, redact, sanitize, neutralize,
|
||||
consent, escape) or line (`shell: true`, `execSync(`, `eval`, `new Function(`,
|
||||
raw HTML sinks, a shell run through argv, recursive deletes). **data** is user data: a path naming pii, gdpr, ccpa, privacy,
|
||||
**money** also reads metering and charge words in a path or on a line (meter,
|
||||
metering, metered, charge, charged, billable, billed, credits) and three
|
||||
pairs: usage beside a token or cost word, proxy beside billing, and a client
|
||||
beside a cost or charge word, so a CLI's usage text and a plain proxy stay out
|
||||
(GST-850, from gbrain-evals #289, whose metering proxy and billing clients read
|
||||
`required=none`). **security** is a trust-boundary path (trust, redact, sanitize, neutralize,
|
||||
consent, escape) or line (`shell: true`, `exec(` and `execSync(`, `eval`, `new Function(`,
|
||||
raw HTML sinks, a shell run through argv, recursive deletes), outbound URL
|
||||
handling (`fetch`, `http(s).request`, axios, got, undici, `requests` or `urlopen`
|
||||
on anything but one string literal, `new URL` from input, redirect toggles, proxy
|
||||
settings and host allowlists), archive extraction and path traversal (`tar.x`,
|
||||
adm-zip, yauzl, extract-zip, `extractall`, `tar -x`, `unzip`, `path.join` or
|
||||
`resolve` on request or entry input, a `'..'` check added or removed), and Node
|
||||
exec through any `child_process` spelling: a named import of `exec`, `execSync`
|
||||
or `spawn` (one line or split over lines), a default or namespace import, a
|
||||
whole-module or destructured `require` or dynamic `import`. **data** is user data: a path naming pii, gdpr, ccpa, privacy,
|
||||
personal, account, customer, cookie, persistence, storage or database, or a line
|
||||
that drops a table or column, deletes rows or bulk-deletes records. Auth scope,
|
||||
migrations and deleted code paths were already gate class. `RISK_RULES` hashes
|
||||
the patterns and `DEPTH_RULES` includes it.
|
||||
|
||||
**UI text and SVGs** stay code (light unless a risk class applies). A docs path
|
||||
for UI copy, locale values and chart SVGs waits for an allowlist classifier: three
|
||||
rounds of R51 review found bypasses in line-pattern versions of both.
|
||||
<a id="allowlisted-docs-paths"></a>
|
||||
**Allowlisted docs paths (GST-850).** Two kinds of path are docs when
|
||||
`lib/review-allowlist.ts` accepts them, and code otherwise; three rounds of R51
|
||||
review found bypasses in line-pattern versions, so both are allowlists that read
|
||||
the whole input in linear time and fail closed on anything they do not parse.
|
||||
|
||||
- **A new SVG** whose whole file is one `svg` root built from presentational
|
||||
elements (`svg`, `g`, `path`, `rect`, `circle`, `ellipse`, `line`, `polyline`,
|
||||
`polygon`, `text`, `tspan`, `title`, `desc`, `defs`, gradients and `stop`,
|
||||
`clipPath`, `use`, `symbol`, `marker`, `pattern`, `mask`) and presentational
|
||||
attributes. No namespace prefixes, `on*` or binding attributes (`:x`, `v-bind:`,
|
||||
`x-bind:`, `@x`), entities other than the five predefined ones, doctype,
|
||||
CDATA or processing instructions other than the XML declaration; `href` only
|
||||
on `use` and only `#fragment`; `url(#id)` only in presentation attributes and
|
||||
no `url(` in `style`; no backslash escapes; no `{{` or `${` in text. An edited
|
||||
SVG stays code.
|
||||
- **A locale resource edit**: a JSON, ARB, YAML, PO, `.strings`, XLIFF, Fluent
|
||||
or `.properties` file under a `locale(s)`, `i18n`, `l10n`, `translation(s)` or
|
||||
`lang` directory, where every deleted line pairs with an added line with the
|
||||
same key and only the literal string value changes, with no markup, encoded
|
||||
character, script or link scheme, and the same placeholders. Added or removed
|
||||
keys, UI component files (`.tsx`, `.vue` and the rest), i18n source files and
|
||||
anything under `config/` stay code.
|
||||
|
||||
A write-up with one clean chart and no code is one reader (the outside voice on
|
||||
its numbers). When a diff publishes numbers, the batched specialist's diff keeps
|
||||
the write-up's text files instead of leaving docs out. `ALLOWLIST_RULES` hashes
|
||||
the lists and patterns and `DEPTH_RULES` includes it.
|
||||
|
||||
**Batched checklists (the lean ensemble, GSTA-167 part 2).** One specialist
|
||||
subagent reads Testing and Security on every code or gate diff, plus
|
||||
@@ -252,7 +291,7 @@ and lockfiles stay runtime. To adopt it, commit an entry like this on the base b
|
||||
|
||||
**Gate paths for the machinery itself.** An edit to `lib/review-depth.ts`,
|
||||
`lib/review-collect.ts`, `bin/gstack-review-plan`, `lib/review-scope.ts`,
|
||||
`lib/review-targeted.ts`, `lib/review-risk.ts`, `review/specialists/targeted.md`, `lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`,
|
||||
`lib/review-targeted.ts`, `lib/review-risk.ts`, `lib/review-allowlist.ts`, `review/specialists/targeted.md`, `lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`,
|
||||
`ship/sections/review-army.md.tmpl`, `ship/sections/adversarial.md.tmpl` or
|
||||
`scripts/resolvers/review-army.ts` is gate class: changing how much review runs gets
|
||||
the most review. `rules=` (`DEPTH_RULES`) hashes the deciding constants and patterns,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* review-allowlist — the two allowlisted docs paths of a /ship review depth (GST-850, docs/review-policy.md#allowlisted-docs-paths):
|
||||
* a new SVG built only from presentational elements and attributes, and a locale resource edit that changes only literal
|
||||
* string values. Both read the whole input with index scanners that only move forward, so time is linear in the input,
|
||||
* and both fail closed: anything they do not recognize keeps the path code. A leaf module: lib/review-depth.ts decides
|
||||
* which paths reach it and folds ALLOWLIST_RULES into DEPTH_RULES.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const SVG_ELEMENTS = ['svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'tspan', 'title', 'desc', 'defs', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'use', 'symbol', 'marker', 'pattern', 'mask'];
|
||||
export const SVG_ATTRIBUTES = [
|
||||
'xmlns', 'version', 'id', 'class', 'style', 'role', 'lang', 'aria-label', 'aria-hidden', 'aria-labelledby', 'aria-describedby',
|
||||
'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'fx', 'fy', 'fr', 'dx', 'dy', 'width', 'height', 'd', 'points', 'pathLength',
|
||||
'viewBox', 'preserveAspectRatio', 'transform', 'overflow', 'display', 'visibility', 'opacity', 'color',
|
||||
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit', 'paint-order', 'vector-effect', 'shape-rendering', 'text-rendering',
|
||||
'font-family', 'font-size', 'font-weight', 'font-style', 'font-variant', 'text-anchor', 'dominant-baseline', 'alignment-baseline', 'letter-spacing', 'word-spacing', 'text-decoration', 'rotate', 'textLength', 'lengthAdjust',
|
||||
'offset', 'stop-color', 'stop-opacity', 'gradientUnits', 'gradientTransform', 'spreadMethod', 'clip-path', 'clip-rule', 'clipPathUnits', 'mask', 'maskUnits', 'maskContentUnits',
|
||||
'patternUnits', 'patternContentUnits', 'patternTransform', 'markerWidth', 'markerHeight', 'markerUnits', 'refX', 'refY', 'orient', 'marker-start', 'marker-mid', 'marker-end', 'href',
|
||||
];
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
const XML_DECL = /^<\?xml version="1\.[01]"(?: encoding="[A-Za-z0-9._-]+")?(?: standalone="(?:yes|no)")?\s*\?>$/;
|
||||
const ENTITY = /^&(?:amp|lt|gt|quot|apos|#[0-9]{1,7}|#x[0-9a-fA-F]{1,6});/;
|
||||
const NAMED_ENTITY = /^&(?:amp|lt|gt|quot|apos);/;
|
||||
const FRAGMENT = /^#[A-Za-z_][\w.-]*$/;
|
||||
const LOCAL_URL = /url\(#[A-Za-z_][\w.-]*\)/g;
|
||||
const STYLE_VALUE = /^[A-Za-z0-9\s#%.,:;()_+-]*$/;
|
||||
|
||||
const isWs = (c: string | undefined) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
|
||||
const isNameChar = (c: string | undefined) => !!c && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '-');
|
||||
/** Every & starts an allowed reference; named allows only the five predefined entities. */
|
||||
function entitiesOk(s: string, named: boolean): boolean {
|
||||
for (let k = s.indexOf('&'); k >= 0; k = s.indexOf('&', k + 1)) if (!(named ? NAMED_ENTITY : ENTITY).test(s.slice(k, k + 12))) return false;
|
||||
return true;
|
||||
}
|
||||
function textOk(s: string, inside: boolean): boolean {
|
||||
if (!inside) { for (const c of s) if (!isWs(c)) return false; return true; }
|
||||
return !/[\0-\x08\x0b\x0c\x0e-\x1f]|\{\{|\$\{|\]\]>/.test(s) && entitiesOk(s, false);
|
||||
}
|
||||
function valueOk(element: string, name: string, v: string): boolean {
|
||||
if (/[<\\`\0-\x1f]/.test(v) || !entitiesOk(v, true)) return false;
|
||||
if (name === 'xmlns') return v === SVG_NS;
|
||||
if (name === 'href') return element === 'use' && FRAGMENT.test(v);
|
||||
if (name === 'style') return STYLE_VALUE.test(v) && !/url\(|expression/i.test(v);
|
||||
return !/url\(/i.test(v.replace(LOCAL_URL, ''));
|
||||
}
|
||||
/** One start tag's body (between < and >, without a trailing /): the element name, or null when anything is off the allowlist. */
|
||||
function tagOk(body: string): string | null {
|
||||
let k = 0;
|
||||
while (k < body.length && isNameChar(body[k]) && body[k] !== '-') k++;
|
||||
const name = body.slice(0, k);
|
||||
if (!SVG_ELEMENTS.includes(name)) return null;
|
||||
const seen = new Set<string>();
|
||||
while (k < body.length) {
|
||||
if (!isWs(body[k])) return null;
|
||||
while (isWs(body[k])) k++;
|
||||
if (k >= body.length) break;
|
||||
const start = k;
|
||||
while (isNameChar(body[k])) k++;
|
||||
const attr = body.slice(start, k);
|
||||
while (isWs(body[k])) k++;
|
||||
if (!SVG_ATTRIBUTES.includes(attr) || seen.has(attr) || body[k] !== '=') return null;
|
||||
k++;
|
||||
while (isWs(body[k])) k++;
|
||||
const q = body[k];
|
||||
if (q !== '"' && q !== "'") return null;
|
||||
const end = body.indexOf(q, k + 1);
|
||||
if (end < 0 || !valueOk(name, attr, body.slice(k + 1, end))) return null;
|
||||
seen.add(attr);
|
||||
k = end + 1;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Whether a whole SVG file is presentational only: one svg root, allowlisted elements and attributes, no prefixes, scripts, entities, doctype or outside references. */
|
||||
export function presentationalSvg(text: string): boolean {
|
||||
let i = text.charCodeAt(0) === 0xfeff ? 1 : 0;
|
||||
if (text.startsWith('<?xml', i)) {
|
||||
const end = text.indexOf('?>', i);
|
||||
if (end < 0 || !XML_DECL.test(text.slice(i, end + 2))) return false;
|
||||
i = end + 2;
|
||||
}
|
||||
const stack: string[] = [];
|
||||
let roots = 0;
|
||||
while (i < text.length) {
|
||||
const lt = text.indexOf('<', i);
|
||||
if (!textOk(text.slice(i, lt < 0 ? text.length : lt), stack.length > 0)) return false;
|
||||
if (lt < 0) break;
|
||||
if (text.startsWith('<!--', lt)) {
|
||||
const end = text.indexOf('-->', lt + 4);
|
||||
if (end < 0 || text.slice(lt + 4, end).includes('--')) return false;
|
||||
i = end + 3;
|
||||
continue;
|
||||
}
|
||||
const gt = text.indexOf('>', lt);
|
||||
if (gt < 0) return false;
|
||||
if (text[lt + 1] === '/') {
|
||||
let end = gt;
|
||||
while (end > lt + 2 && isWs(text[end - 1])) end--;
|
||||
if (!stack.length || stack.pop() !== text.slice(lt + 2, end)) return false;
|
||||
} else {
|
||||
const selfClosing = text[gt - 1] === '/';
|
||||
const name = tagOk(text.slice(lt + 1, selfClosing ? gt - 1 : gt));
|
||||
if (!name || (!stack.length && (roots++ > 0 || name !== 'svg'))) return false;
|
||||
if (!selfClosing) stack.push(name);
|
||||
else if (!stack.length) return false;
|
||||
}
|
||||
i = gt + 1;
|
||||
}
|
||||
return roots === 1 && stack.length === 0;
|
||||
}
|
||||
|
||||
export const LOCALE_DIR = /(^|\/)(locales?|i18n|l10n|translations?|lang)\//i;
|
||||
export const LOCALE_FORMATS = ['json', 'arb', 'yaml', 'yml', 'po', 'strings', 'xliff', 'xlf', 'ftl', 'properties'];
|
||||
const PLACEHOLDER = /\{\{[^{}]*\}\}|\{[^{}]*\}|%(?:[1-9]\d*\$)?[-+ #0]*(?:[1-9]\d*)?(?:\.\d+)?[a-zA-Z@]|\$\{[^{}]*\}|\$t\([^()]*\)|@[:.][\w.:-]+|\$[A-Za-z_]\w*/g;
|
||||
/** Never UI text: markup, an encoded character that could spell markup, a script or link scheme, a template literal. */
|
||||
const NOT_TEXT = /[<>`]|&(?:lt|gt|#)|\\[uUx]|(?:javascript|vbscript|data):|:\/\//i;
|
||||
const YAML_PLAIN = /^[A-Za-z0-9][^#:{}[\]&*!|>%@`"'<\\]*$/;
|
||||
const XLIFF_TARGET = /^(\s*<target(?:\s+state="[a-z-]+")?>)([^<]*)(<\/target>\s*)$/;
|
||||
const FTL_ENTRY = /^(\s*[.-]?[A-Za-z][\w-]*\s*=\s*)(\S.*)$/;
|
||||
const PROPERTIES_ENTRY = /^(\s*[A-Za-z0-9_.-]+\s*[=:]\s*)(\S.*)$/;
|
||||
|
||||
const skipWs = (s: string, k: number) => { while (s[k] === ' ' || s[k] === '\t') k++; return k; };
|
||||
/** The index of the quote that closes a string opened before k, or -1; a backslash escapes the next character, or, doubled, two quotes stand for one (YAML single quotes). */
|
||||
function closeQuote(s: string, k: number, q: string, doubled = false): number {
|
||||
while (k < s.length) {
|
||||
if (doubled && s[k] === q && s[k + 1] === q) k += 2;
|
||||
else if (s[k] === q) return k;
|
||||
else k += !doubled && s[k] === '\\' ? 2 : 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
type Entry = { key: string; value: string } | null;
|
||||
/** "<key>" <sep> "<value>" <tail>: the JSON, ARB and Apple .strings line shape. */
|
||||
function quotedPair(line: string, sep: string, tail: RegExp): Entry {
|
||||
let k = skipWs(line, 0);
|
||||
if (line[k] !== '"') return null;
|
||||
k = closeQuote(line, k + 1, '"');
|
||||
if (k < 0) return null;
|
||||
k = skipWs(line, k + 1);
|
||||
if (line[k] !== sep) return null;
|
||||
k = skipWs(line, k + 1);
|
||||
if (line[k] !== '"') return null;
|
||||
const end = closeQuote(line, k + 1, '"');
|
||||
if (end < 0 || !tail.test(line.slice(end + 1))) return null;
|
||||
return { key: line.slice(0, k + 1) + '\0' + line.slice(end), value: line.slice(k + 1, end) };
|
||||
}
|
||||
function yamlPair(line: string): Entry {
|
||||
const start = skipWs(line, 0);
|
||||
let k = start;
|
||||
const q = line[k];
|
||||
if (q === '"' || q === "'") k = closeQuote(line, k + 1, q, q === "'") + 1;
|
||||
else while (k < line.length && /[\w.-]/.test(line[k]!)) k++;
|
||||
if (k <= start || line[k] !== ':' || (line[k + 1] !== ' ' && line[k + 1] !== '\t')) return null;
|
||||
k = skipWs(line, k + 1);
|
||||
const v = line[k];
|
||||
let end = line.length;
|
||||
while (end > k && isWs(line[end - 1])) end--;
|
||||
if (v === '"' || v === "'") {
|
||||
const close = closeQuote(line, k + 1, v, v === "'");
|
||||
if (close !== end - 1) return null;
|
||||
return { key: line.slice(0, k + 1) + '\0' + line.slice(close), value: line.slice(k + 1, close) };
|
||||
}
|
||||
return YAML_PLAIN.test(line.slice(k, end)) ? { key: line.slice(0, k), value: line.slice(k, end) } : null;
|
||||
}
|
||||
function poPair(line: string): Entry {
|
||||
const m = /^msgstr(?:\[\d+\])? "/.exec(line);
|
||||
if (!m) return null;
|
||||
const end = closeQuote(line, m[0].length, '"');
|
||||
return end >= 0 && /^\s*$/.test(line.slice(end + 1)) ? { key: m[0] + '\0' + line.slice(end), value: line.slice(m[0].length, end) } : null;
|
||||
}
|
||||
function regexPair(line: string, r: RegExp): Entry {
|
||||
const m = r.exec(line);
|
||||
return m ? { key: m[1]! + '\0' + (m[3] ?? ''), value: m[2]! } : null;
|
||||
}
|
||||
function entryOf(format: string, line: string): Entry {
|
||||
switch (format) {
|
||||
case 'json': case 'arb': return quotedPair(line, ':', /^\s*,?\s*$/);
|
||||
case 'strings': return quotedPair(line, '=', /^\s*;\s*$/);
|
||||
case 'yaml': case 'yml': return yamlPair(line);
|
||||
case 'po': return poPair(line);
|
||||
case 'xliff': case 'xlf': return regexPair(line, XLIFF_TARGET);
|
||||
case 'ftl': return regexPair(line, FTL_ENTRY);
|
||||
case 'properties': return /\\\s*$/.test(line) ? null : regexPair(line, PROPERTIES_ENTRY);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** The value's placeholders, sorted, or null when it is not plain text (markup, an encoded character, a scheme, an unpaired brace). */
|
||||
function placeholders(value: string): string | null {
|
||||
if (NOT_TEXT.test(value)) return null;
|
||||
const found = value.match(PLACEHOLDER) ?? [];
|
||||
if (/[{}]/.test(value.replace(PLACEHOLDER, ''))) return null;
|
||||
return found.sort().join('\0');
|
||||
}
|
||||
|
||||
/** The resource format of a path under a locale directory, or null. */
|
||||
export function localeFormat(p: string): string | null {
|
||||
const ext = /\.([A-Za-z]+)$/.exec(p)?.[1]?.toLowerCase();
|
||||
return ext && LOCALE_FORMATS.includes(ext) && LOCALE_DIR.test(p) ? ext : null;
|
||||
}
|
||||
|
||||
/** Whether an edit to a locale resource changes only literal string values: lines pair up with the same key, and each value is plain text with the same placeholders. */
|
||||
export function localeTextOnly(p: string, removed: readonly string[] = [], added: readonly string[] = []): boolean {
|
||||
const format = localeFormat(p);
|
||||
if (!format || !added.length || removed.length !== added.length) return false;
|
||||
return removed.every((line, n) => {
|
||||
const a = entryOf(format, line), b = entryOf(format, added[n]!);
|
||||
if (!a || !b || a.key !== b.key) return false;
|
||||
const ha = placeholders(a.value);
|
||||
return ha !== null && ha === placeholders(b.value);
|
||||
});
|
||||
}
|
||||
|
||||
/** A hash of the lists and patterns that decide an allowlisted docs path; lib/review-depth.ts folds it into DEPTH_RULES. */
|
||||
export const ALLOWLIST_RULES = createHash('sha256').update(JSON.stringify({
|
||||
SVG_ELEMENTS, SVG_ATTRIBUTES, SVG_NS, LOCALE_FORMATS,
|
||||
patterns: [XML_DECL, ENTITY, NAMED_ENTITY, FRAGMENT, LOCAL_URL, STYLE_VALUE, LOCALE_DIR, PLACEHOLDER, NOT_TEXT, YAML_PLAIN, XLIFF_TARGET, FTL_ENTRY, PROPERTIES_ENTRY].map(r => '/' + r.source + '/' + r.flags),
|
||||
})).digest('hex').slice(0, 16);
|
||||
+10
-7
@@ -20,6 +20,8 @@ export interface Collected extends ClassInput {
|
||||
errors: DepthError[];
|
||||
deleted: string[];
|
||||
stampOnly: string[];
|
||||
/** Paths the diff creates (untracked files included): their added lines are the whole file. */
|
||||
created: string[];
|
||||
/** Added lines per path, for scanNumbers. */
|
||||
added: Record<string, string[]>;
|
||||
/** Deleted lines per path, for the targeted classes (lib/review-targeted.ts). */
|
||||
@@ -54,11 +56,11 @@ export function loadRegistryAt(root: string, sha: string): RegistryInfo {
|
||||
}
|
||||
|
||||
/** Numstat, deletions and the -U0 patch for `range` (`[mergeBase, head]` or `[mergeBase]` for the working tree). */
|
||||
function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Collected, 'perPath' | 'deleted' | 'stampOnly' | 'added' | 'removed'> {
|
||||
function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Collected, 'perPath' | 'deleted' | 'stampOnly' | 'created' | 'added' | 'removed'> {
|
||||
const numstat = git(root, ['diff', '--numstat', '-z', '--no-renames', ...range]);
|
||||
const status = git(root, ['diff', '--name-status', '-z', '--no-renames', '--diff-filter=D', ...range]);
|
||||
const patch = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', ...range]);
|
||||
const empty = { perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} };
|
||||
const empty = { perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} };
|
||||
for (const [r, what] of [[numstat, '--numstat'], [status, '--name-status'], [patch, '-U0']] as const) {
|
||||
if (!r.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(r.err || 'git diff failed'), input: `git diff ${what} ${range.join(' ')}`, repair: 'check that both commits exist locally (git fetch origin <base>) and rerun' }); return empty; }
|
||||
}
|
||||
@@ -73,6 +75,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Col
|
||||
const added: Record<string, string[]> = {};
|
||||
const removed: Record<string, string[]> = {};
|
||||
const changed: Record<string, string[]> = {};
|
||||
const created: string[] = [];
|
||||
let cur = '';
|
||||
let minus = '';
|
||||
let inHunk = false;
|
||||
@@ -80,7 +83,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Col
|
||||
if (line.startsWith('diff --git ')) { inHunk = false; cur = ''; minus = ''; continue; }
|
||||
if (!inHunk) {
|
||||
if (line.startsWith('--- ')) minus = line === '--- /dev/null' ? '' : line.slice(6).replace(/\t$/, '');
|
||||
else if (line.startsWith('+++ ')) cur = line === '+++ /dev/null' ? minus : line.slice(6).replace(/\t$/, '');
|
||||
else if (line.startsWith('+++ ')) { cur = line === '+++ /dev/null' ? minus : line.slice(6).replace(/\t$/, ''); if (!minus && cur && !scratch(cur)) created.push(cur); }
|
||||
else if (line.startsWith('@@')) inHunk = true;
|
||||
continue;
|
||||
}
|
||||
@@ -90,7 +93,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Col
|
||||
if (line[0] === '+' || line[0] === '-') (changed[cur] ??= []).push(line);
|
||||
}
|
||||
const stampOnly = perPath.map(e => e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:\s*"[^"]*"\s*,?\s*$/.test(l)));
|
||||
return { perPath, deleted, stampOnly, added, removed };
|
||||
return { perPath, deleted, stampOnly, created, added, removed };
|
||||
}
|
||||
|
||||
/** More data candidates than this keep every data file code: the consumer scan is not run. */
|
||||
@@ -158,7 +161,7 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C
|
||||
const mb = git(root, ['merge-base', baseSha, headSha]);
|
||||
if (!mb.ok) {
|
||||
errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin <base>) and rerun' });
|
||||
return result({ perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
|
||||
return result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
|
||||
}
|
||||
const mergeBase = mb.out.trim();
|
||||
const diff = readDiff(root, [mergeBase, headSha], errors);
|
||||
@@ -176,7 +179,7 @@ export function collectLive(root: string, base: string): Collected {
|
||||
const mb = git(root, ['merge-base', ref, 'HEAD']);
|
||||
if (!mb.ok) {
|
||||
errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` });
|
||||
return result({ perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
|
||||
return result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
|
||||
}
|
||||
const mergeBase = mb.out.trim();
|
||||
const diff = readDiff(root, [mergeBase], errors);
|
||||
@@ -190,7 +193,7 @@ export function collectLive(root: string, base: string): Collected {
|
||||
const text = binary ? '' : buf.toString('utf8');
|
||||
const lines = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
|
||||
diff.perPath.push({ path: p, lines, binary });
|
||||
if (!binary) diff.added[p] = text.split('\n');
|
||||
if (!binary) { diff.added[p] = text.split('\n'); diff.created.push(p); }
|
||||
}
|
||||
const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [mergeBase], true, root, errors);
|
||||
return result(diff, { root, registry, errors, mergeBase, untracked, consumed });
|
||||
|
||||
+21
-5
@@ -13,6 +13,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { ALLOWLIST_RULES, localeTextOnly, presentationalSvg } from './review-allowlist';
|
||||
import { matchRisk, RISK_RULES, type RiskClass } from './review-risk';
|
||||
|
||||
export type DiffClass = 'docs' | 'tests' | 'code' | 'gate';
|
||||
@@ -55,7 +56,7 @@ const RELEASE_METADATA = [/^CHANGELOG\.md$/, /^VERSION$/, /^agents-digest\//];
|
||||
/** Gate paths, the review-depth machinery included: an edit to how much review runs gets the most review. */
|
||||
const GATE_PATH = [
|
||||
/(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/,
|
||||
/^lib\/review-(depth|collect|scope|targeted|risk)\.ts$/, /^review\/specialists\/targeted\.md$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/,
|
||||
/^lib\/review-(depth|collect|scope|targeted|risk|allowlist)\.ts$/, /^review\/specialists\/targeted\.md$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/,
|
||||
];
|
||||
|
||||
/** Data files: JSON, CSV, TSV and raster images. YAML, TOML and SVG are never data here: deploy configs, API specs and executable SVG stay code. */
|
||||
@@ -68,7 +69,7 @@ const DATA_CONFIG = [
|
||||
|
||||
/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */
|
||||
export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({
|
||||
RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT,
|
||||
RISK_RULES, ALLOWLIST_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT,
|
||||
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
|
||||
})).digest('hex').slice(0, 16);
|
||||
|
||||
@@ -131,7 +132,9 @@ export interface ClassInput {
|
||||
* (lib/review-collect.ts). A candidate not listed counts as docs; 'all' or no scan keeps every data file code.
|
||||
*/
|
||||
consumed?: readonly string[] | 'all';
|
||||
/** Added and deleted lines per path (lib/review-collect.ts): the risk classes read them; without them no line signal applies. */
|
||||
/** Paths the diff creates (untracked files included): a new SVG is an allowlist candidate only when its added lines are the whole file. */
|
||||
created?: readonly string[];
|
||||
/** Added and deleted lines per path (lib/review-collect.ts): the allowlisted docs paths and the risk classes read them; without them neither applies. */
|
||||
added?: Record<string, readonly string[]>;
|
||||
removed?: Record<string, readonly string[]>;
|
||||
}
|
||||
@@ -140,7 +143,16 @@ export interface ClassResult { class: DiffClass; reasons: string[]; errors: Dept
|
||||
const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200);
|
||||
const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p);
|
||||
const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && dataCandidate(p, i.root);
|
||||
const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) || unconsumedData(i, p) ? 'docs' : classifyPath(p, i.root));
|
||||
/**
|
||||
* An allowlisted docs path (GST-850, lib/review-allowlist.ts): a new SVG whose whole file is presentational, or a locale resource
|
||||
* edit that changes only literal string values. Agent prose, gate paths and config files never qualify; an edited SVG stays code.
|
||||
*/
|
||||
const allowlisted = (i: ClassInput, p: string): 'svg' | 'locale' | null => {
|
||||
if (!i.added?.[p]?.length || classifyPath(p, i.root) !== 'code' || [...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p))) return null;
|
||||
if (/\.svg$/i.test(p)) return i.created?.includes(p) && !i.removed?.[p]?.length && presentationalSvg(i.added[p]!.join('\n')) ? 'svg' : null;
|
||||
return localeTextOnly(p, i.removed?.[p], i.added[p]) ? 'locale' : null;
|
||||
};
|
||||
const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) || unconsumedData(i, p) || allowlisted(i, p) ? 'docs' : classifyPath(p, i.root));
|
||||
|
||||
/** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */
|
||||
export function classifyDiff(i: ClassInput): ClassResult {
|
||||
@@ -152,6 +164,10 @@ export function classifyDiff(i: ClassInput): ClassResult {
|
||||
for (const p of i.paths) { const c = pathClass(i, p); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); }
|
||||
const data = i.paths.filter(p => unconsumedData(i, p));
|
||||
if (data.length) reasons.push(`docs: ${data.length} data path(s) no code names`);
|
||||
for (const kind of ['svg', 'locale'] as const) {
|
||||
const n = i.paths.filter(p => allowlisted(i, p) === kind).length;
|
||||
if (n) reasons.push(`docs: ${n} ${kind === 'svg' ? 'presentational SVG' : 'locale text'} path(s)`);
|
||||
}
|
||||
const s = i.scope ?? {};
|
||||
if (s.SCOPE_ERROR) {
|
||||
raise('code', `scope error: ${s.SCOPE_ERROR}`);
|
||||
@@ -395,7 +411,7 @@ export function planReview(i: PlanInput): ReviewPlan {
|
||||
const skipped: string[] = [];
|
||||
const dispatches: Dispatch[] = [];
|
||||
const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial<Dispatch> = {}) => dispatches.push({ role, name, effort, exclude, ...extra });
|
||||
const docsAndOutputs = i.paths.filter(p => pathClass(i, p) === 'docs' || isOutput(i, p)).map(excludeSpec);
|
||||
const docsAndOutputs = i.paths.filter(p => (pathClass(i, p) === 'docs' && !(i.numbers.publishes && DOC_PATH.test(p))) || isOutput(i, p)).map(excludeSpec);
|
||||
const numbersChanged = i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint;
|
||||
const firstVoices = h.fullPasses === 0 || !i.adversarialRan;
|
||||
const specialist: Effort = i.tiers === false ? 'host' : ROLE_EFFORT.specialist;
|
||||
|
||||
+40
-8
@@ -4,16 +4,23 @@
|
||||
* and deleted lines, so it imports neither the depth nor the targeted module.
|
||||
*
|
||||
* money, auth and secret are the targeted pass's matchers (GST-257). security is a trust-boundary path or line
|
||||
* (a redaction, sanitizer, consent or trust module; a shell, eval or raw HTML sink); data is user data (a path
|
||||
* naming personal or stored data, or a line that drops or bulk-deletes it). Path and identifier signals are
|
||||
* token-bounded: costume never names cost.
|
||||
* (a redaction, sanitizer, consent or trust module; a shell, eval or raw HTML sink; outbound URL handling; archive
|
||||
* extraction and path traversal; Node exec through any import spelling); data is user data (a path naming personal or
|
||||
* stored data, or a line that drops or bulk-deletes it). Path and identifier signals are token-bounded: costume never
|
||||
* names cost, and a CLI's usage text never names money.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const RISK_CLASSES = ['money', 'auth', 'secret', 'security', 'data'] as const;
|
||||
export type RiskClass = (typeof RISK_CLASSES)[number];
|
||||
|
||||
export const MONEY_PATH_TOKENS = ['cost', 'costs', 'spend', 'spending', 'budget', 'budgets', 'ledger', 'billing', 'price', 'prices', 'pricing', 'fee', 'fees', 'invoice', 'invoices'];
|
||||
/** Metering and charge words (GST-850: gbrain-evals #289 changed a metering proxy and billing clients with none of the older tokens); they also count on a line. */
|
||||
export const MONEY_LINE_TOKENS = ['meter', 'meters', 'metering', 'metered', 'charge', 'charges', 'charged', 'billable', 'billed', 'credits'];
|
||||
export const MONEY_PATH_TOKENS = ['cost', 'costs', 'spend', 'spending', 'budget', 'budgets', 'ledger', 'billing', 'price', 'prices', 'pricing', 'fee', 'fees', 'invoice', 'invoices', ...MONEY_LINE_TOKENS];
|
||||
/** Words that name money only beside another in the same path or line: token usage, a billing proxy, a cost or charge client. */
|
||||
export const MONEY_PAIRS: readonly (readonly [string, readonly string[]])[] = [
|
||||
['usage', ['token', 'tokens', 'cost', 'costs', 'spend', 'billing', 'price', 'pricing', 'budget']], ['proxy', ['billing', 'bill']], ['client', ['cost', 'costs', 'charge', 'charges', 'billing', 'price', 'pricing', 'spend']],
|
||||
];
|
||||
/** Identifiers ending in _usd, Usd or cap_usd, any spend_cap spelling, and paid-provider call sites (the providers lib/pricing.ts prices). */
|
||||
export const MONEY_LINE = [
|
||||
/\b[A-Za-z0-9_]*_usd\b/i, /\b[a-z][A-Za-z0-9]*Usd\b/, /\bspend[_-]?cap\b/i,
|
||||
@@ -33,8 +40,23 @@ export const SECRET_LINE = [
|
||||
];
|
||||
|
||||
const SECURITY_PATH_TOKENS = ['trust', 'redact', 'redaction', 'redactor', 'sanitize', 'sanitizer', 'neutralize', 'neutralizer', 'consent', 'escape'];
|
||||
/** An argument that is one plain string literal: outbound calls and URL building on anything else are input-driven. */
|
||||
const NOT_LITERAL = String.raw`\s*\(\s*(?![rbu]?['"][^'"\\]*['"]\s*[,)])`;
|
||||
const CHILD_PROCESS = String.raw`['"](?:node:)?child_process['"]`;
|
||||
const SECURITY_LINE = [
|
||||
/\bshell\s*:\s*true\b/, /\bshell\s*=\s*True\b/, /(?<![.\w])execSync\s*\(/, /\bchild_process\.exec(?:Sync)?\s*\(/, /\bos\.system\s*\(/,
|
||||
/\bshell\s*:\s*true\b/, /\bshell\s*=\s*True\b/, /(?<![.\w$])exec(?:Sync)?\s*\(/, /\b(?:child_process|childProcess|cp)\.exec(?:Sync)?\s*\(/, /\bos\.system\s*\(/,
|
||||
new RegExp(String.raw`^(?=.*${CHILD_PROCESS})(?=.*\b(?:exec|execSync|spawn)\b)\s*(?:import|const|let|var)\b(?!\s+type\b)`),
|
||||
new RegExp(String.raw`\bimport\s+(?:\*\s+as\s+)?[\w$]+\s*(?:,\s*\{[^}]*\}\s*)?from\s*${CHILD_PROCESS}`), new RegExp(String.raw`(?<!\}\s*=\s*(?:await\s+)?)\b(?:require|import)\s*\(\s*${CHILD_PROCESS}`),
|
||||
new RegExp(String.raw`(?:(?<![.\w$])|\b(?:globalThis|window|self)\.)fetch${NOT_LITERAL}`), new RegExp(String.raw`\bhttps?\.(?:request|get)${NOT_LITERAL}`),
|
||||
new RegExp(String.raw`\baxios(?:\.(?:get|post|put|patch|delete|head|options|request))?${NOT_LITERAL}`), new RegExp(String.raw`(?<![.\w$])got(?:\.(?:get|post|put|patch|delete|head|stream))?${NOT_LITERAL}`),
|
||||
new RegExp(String.raw`\b(?:requests|httpx)\.(?:get|post|put|patch|delete|head|request)${NOT_LITERAL}`), new RegExp(String.raw`\burlopen${NOT_LITERAL}`), new RegExp(String.raw`\bnew\s+URL\s*\(\s*(?!['"][^'"\\]*['"]\s*\)|import\.meta\.url\s*\))`),
|
||||
/\b(?:from\s+|require\s*\(\s*)['"]undici['"]|\bundici\.(?:request|fetch|stream)\s*\(|\burllib3\b|\b(?:urljoin|url\.resolve)\s*\(/,
|
||||
/\bredirect\s*:\s*['"](?:follow|manual)['"]|\b(?:maxRedirects|followRedirects?|follow_redirects|allow_redirects)\s*[:=]/,
|
||||
/\b(?:HTTPS?_PROXY|NO_PROXY|ALL_PROXY|https?_proxy|no_proxy)\b|\b(?:Https?)?ProxyAgent\b|\bproxy\s*(?::|=(?![=>]))/, /\b(?:allowed|trusted|blocked|denied)[_-]?(?:hosts?|domains?|origins?|urls?)|\b(?:hosts?|domains?|origins?|urls?)[_-]?(?:allow|deny|block)[_-]?list/i,
|
||||
/\btar\.(?:x|extract)\s*\(|\.(?:extractall|extractAllTo|extractEntryTo)\s*\(|\b(?:tar|tf|zf|zip|archive)\.extract\s*\(|\bshutil\.unpack_archive\s*\(|\b(?:adm-zip|AdmZip|yauzl|extract-zip|unzipper|node-stream-zip)\b/,
|
||||
/\btar\s+(?:-[A-Za-z]*x|x[A-Za-z]*\s|--extract)|\bunzip\s+[-\w"'$]/,
|
||||
/\bpath\.(?:join|resolve)\s*\([^)]*(?:\b(?:req|request|params|query|argv|headers?|filename|fileName|entryName|member|upload|untrusted|userInput)\b|\bentry\.(?:path|name|fileName)\b)/,
|
||||
/\b(?:includes|startsWith|endsWith|indexOf)\s*\(\s*['"`]\.\.[/\\]{0,2}['"`]|[!=]==?\s*['"`]\.\.['"`]|['"`]\.\.['"`]\s*[!=]==?|\\\.\\\./,
|
||||
/(?<![.\w])eval\s*\(/, /(?:^|[;&|])\s*eval\s+["'$]/, /\bnew\s+Function\s*\(/,
|
||||
/\b(?:spawn|spawnSync|execFile|execFileSync)\s*\(\s*['"](?:\/bin\/)?(?:ba|z)?sh['"]/, /\bBun\.spawn(?:Sync)?\s*\(\s*\[\s*['"](?:\/bin\/)?(?:ba|z)?sh['"]/,
|
||||
/\brmSync\s*\([^)]*recursive\s*:\s*true/, /\brm\s+-(?:[a-z]*r[a-z]*f|[a-z]*f[a-z]*r)\b/,
|
||||
@@ -46,10 +68,17 @@ const DATA_LINE = [
|
||||
/\.(?:deleteMany|destroyAll|bulkDelete)\s*\(/, /\b(?:destroy_all|delete_all)\b/,
|
||||
];
|
||||
|
||||
/** A named import split over lines (`import {\n exec,\n} from 'node:child_process'`): read over a path's joined added or deleted lines. */
|
||||
const SECURITY_TEXT = new RegExp(String.raw`\bimport\s*\{([^}]*)\}\s*from\s*${CHILD_PROCESS}`, 'g');
|
||||
const EXEC_NAME = /(?:^|[\s,{])(?:exec|execSync|spawn)(?:\s+as\s+[\w$]+)?\s*(?:,|$)/;
|
||||
/** Whether a path or line's tokens name money: a metering or charge word (a cost word too, for a path), or a pair. */
|
||||
const namesMoney = (t: readonly string[], words: readonly string[]) => t.some(x => words.includes(x)) || MONEY_PAIRS.some(([w, beside]) => t.includes(w) && t.some(x => beside.includes(x)));
|
||||
|
||||
/** A hash of the patterns that decide the risk classes, not of this file's bytes; lib/review-depth.ts folds it into DEPTH_RULES. */
|
||||
export const RISK_RULES = createHash('sha256').update(JSON.stringify({
|
||||
RISK_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, SECURITY_PATH_TOKENS, DATA_PATH_TOKENS,
|
||||
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, DATA_LINE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]),
|
||||
MONEY_LINE_TOKENS, MONEY_PAIRS,
|
||||
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH], SECURITY_LINE, SECURITY_TEXT: [SECURITY_TEXT], DATA_LINE }).map(([k, v]) => [k, v.map(r => '/' + r.source + '/' + r.flags)]),
|
||||
})).digest('hex').slice(0, 16);
|
||||
|
||||
export const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||
@@ -71,11 +100,14 @@ export function matchRisk(i: RiskInput): Map<RiskClass, string> {
|
||||
const t = tokens(p);
|
||||
if (i.scopeAuth && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', 'auth path: ' + p);
|
||||
if (SECRET_PATH.test(p)) hit('secret', 'secret path: ' + p);
|
||||
if (t.some(x => MONEY_PATH_TOKENS.includes(x))) hit('money', 'money path: ' + p);
|
||||
if (namesMoney(t, MONEY_PATH_TOKENS)) hit('money', 'money path: ' + p);
|
||||
if (t.some(x => SECURITY_PATH_TOKENS.includes(x))) hit('security', 'security path: ' + p);
|
||||
if (t.some(x => DATA_PATH_TOKENS.includes(x))) hit('data', 'data path: ' + p);
|
||||
for (const side of [i.added?.[p], i.removed?.[p]]) {
|
||||
if (!why.has('security') && side?.length && [...side.join('\n').matchAll(SECURITY_TEXT)].some(m => EXEC_NAME.test(m[1]!.trim()) && !/^\s*type\b/.test(m[1]!))) hit('security', 'security import: ' + p);
|
||||
}
|
||||
for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) {
|
||||
if (!why.has('money') && MONEY_LINE.some(r => r.test(line))) hit('money', 'money line: ' + p);
|
||||
if (!why.has('money') && (MONEY_LINE.some(r => r.test(line)) || namesMoney(tokens(line), MONEY_LINE_TOKENS))) hit('money', 'money line: ' + p);
|
||||
if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', 'secret line: ' + p);
|
||||
if (!why.has('security') && SECURITY_LINE.some(r => r.test(line))) hit('security', 'security line: ' + p);
|
||||
if (!why.has('data') && DATA_LINE.some(r => r.test(line))) hit('data', 'data line: ' + p);
|
||||
|
||||
@@ -21,14 +21,14 @@ import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { classifyPath } from './review-depth';
|
||||
import { AUTH_PATH_PREFIXES, AUTH_PATH_TOKENS, KEY_NAME, MONEY_LINE, MONEY_PATH_TOKENS, SECRET_LINE, SECRET_PATH, matchRisk } from './review-risk';
|
||||
import { AUTH_PATH_PREFIXES, AUTH_PATH_TOKENS, KEY_NAME, MONEY_LINE, MONEY_LINE_TOKENS, MONEY_PAIRS, MONEY_PATH_TOKENS, SECRET_LINE, SECRET_PATH, matchRisk } from './review-risk';
|
||||
|
||||
export const TARGETED_CLASSES = ['money', 'gate', 'auth', 'secret'] as const;
|
||||
export type TargetedClass = (typeof TARGETED_CLASSES)[number];
|
||||
|
||||
/** A hash of the patterns that decide the classes, not of this file's bytes: a comment edit keeps it, a pattern edit changes it. */
|
||||
export const TARGETED_RULES = createHash('sha256').update(JSON.stringify({
|
||||
TARGETED_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME,
|
||||
TARGETED_CLASSES, MONEY_PATH_TOKENS, MONEY_LINE_TOKENS, MONEY_PAIRS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME,
|
||||
patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
|
||||
})).digest('hex').slice(0, 16);
|
||||
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"source": "garrytan/gbrain-evals#289 (merge 513de26d): changed lines of its code-class paths, read as text via gh pr diff",
|
||||
"paths": [
|
||||
"CHANGELOG.md",
|
||||
"docs/benchmarks/cat40-hard/accepted-drift.json",
|
||||
"docs/benchmarks/cat40-hard/calibration.md",
|
||||
"docs/usage-receipt.md",
|
||||
"eval/runner/cat40/loop.ts",
|
||||
"eval/runner/harness-metering-proxy.ts",
|
||||
"eval/runner/providers/openai-compatible.ts",
|
||||
"eval/runner/q1/cell.ts",
|
||||
"eval/runner/systems/baselines.ts",
|
||||
"eval/workload-suites/bench-infra.ts",
|
||||
"scripts/particle-canary.ts",
|
||||
"test/eval/cat40-particle-loop.test.ts",
|
||||
"test/eval/file-agent.test.ts",
|
||||
"test/eval/harness-metering-proxy.test.ts",
|
||||
"test/eval/memory-qa-particle.test.ts",
|
||||
"test/eval/openai-compatible.test.ts",
|
||||
"test/eval/particle-canary.test.ts",
|
||||
"test/eval/q1-cell.test.ts",
|
||||
"test/eval/workload-particle.test.ts"
|
||||
],
|
||||
"added": {
|
||||
"eval/runner/cat40/loop.ts": [
|
||||
"/** A provider's non-2xx answer, after backoff for retryable statuses, or a particle 2xx without a message (its 2xx status). */",
|
||||
" * Particle runs only: the run ended on a turn that timed out, lost its connection after it went out, or was answered",
|
||||
" * 2xx without a message (`compatMayHaveBilled`). Particle may have billed that turn, so a caller never runs the",
|
||||
" * question again.",
|
||||
" * recorded, and a 2xx that is not JSON or has no `choices[0].message` is recorded and thrown as a ProviderError with",
|
||||
" * its 2xx status, never sent again, because the provider answered it and may have billed it (issue #280). A `compat`",
|
||||
" * request is sent with `compatDeadlineMs` instead of Bun's 300-second fetch timeout, and a 504 (a gateway's or the lease",
|
||||
" const answered = new ProviderError(res.status, `${compat} answered ${res.status} ${parsed ? 'without choices[0].message' : 'with a body that is not JSON'}: ${text.slice(0, 300)}`);",
|
||||
" onAttempt?.({ status: 'error', response: json, error: answered.message, ms: Date.now() - t, retry_after_ms: null });",
|
||||
" throw answered;"
|
||||
],
|
||||
"eval/runner/harness-metering-proxy.ts": [
|
||||
" * A particle request that failed after it went out (the upstream deadline",
|
||||
" * passed, the connection reset or closed, or the response body failed to",
|
||||
" * read) is answered 504 `mpw_proxy_upstream_lost`, which no particle client",
|
||||
" * sends again, because Particle may have billed it. Any other upstream",
|
||||
" * failure, such as a refused connection, is a 502",
|
||||
" * `mpw_proxy_upstream_unreachable` that a client may send again (issue #282).",
|
||||
" *",
|
||||
"import { carriesKeyInCleartext, COMPAT_PROVIDERS, compatConnectionLost } from './providers/openai-compatible.ts';",
|
||||
" const lost = provider === 'particle' && ((error as Error).name === 'TimeoutError' || compatConnectionLost(error));",
|
||||
" const message = `metering proxy ${lost ? 'lost the request it sent to' : 'could not reach'} ${provider}: ${(error as Error).message}`;",
|
||||
" return finish(lost ? errorResponse(504, 'mpw_proxy_upstream_lost', message) : errorResponse(502, 'mpw_proxy_upstream_unreachable', message), { ...settle(null, 0, message), error: message });",
|
||||
" return finish(provider === 'particle' ? errorResponse(504, 'mpw_proxy_upstream_lost', message) : errorResponse(502, 'mpw_proxy_upstream_unreachable', message), { ...settle(null, upstream.status, message), error: message });"
|
||||
],
|
||||
"eval/runner/providers/openai-compatible.ts": [
|
||||
"/** A non-2xx answer that is not a budget stop, or a 2xx answer without a usable `choices[0].message` (`compatSend`). */",
|
||||
" * A request the provider may have billed: it timed out (`compatTimedOut`), its connection was lost after it went out",
|
||||
" * (`compatConnectionLost`), or it was answered 2xx without a usable `choices[0].message` (an error carrying that 2xx",
|
||||
" * status, issue #280). A caller never sends it again, with or without a metering proxy in front.",
|
||||
" const status = (e as { status?: unknown } | null)?.status;",
|
||||
" return compatTimedOut(e) || compatConnectionLost(e) || (typeof status === 'number' && status >= 200 && status < 300);",
|
||||
" * decides any retry. A 2xx answer without `choices[0].message`, or whose body is not JSON, is an error carrying its 2xx",
|
||||
" * status and the usage it reported, never an empty answer, and it is not retryable: the provider answered, so it may",
|
||||
" * have billed it (`compatMayHaveBilled`, issue #280). `timeoutMs` (300 s unless given) is the only deadline: Bun's own",
|
||||
" * 300-second fetch timeout is off, so a longer deadline holds.",
|
||||
" catch { throw new CompatHttpError({ provider: req.provider, status: res.status, kind: 'server', retryable: false, retryAfterMs: null, body: `response body is not JSON: ${text.slice(0, 200)}` }); }",
|
||||
" if (!Array.isArray(choices) || !choices[0]?.message) throw new CompatHttpError({ provider: req.provider, status: res.status, kind: 'server', retryable: false, retryAfterMs: null, body: `response has no choices[0].message: ${text.slice(0, 200)}` }, parsed.usage);"
|
||||
],
|
||||
"eval/runner/q1/cell.ts": [
|
||||
" /** `final`: a try that must not be sent again (an OpenAI-compatible request that timed out, lost its connection or was answered 2xx without a message, which the provider may have billed). */"
|
||||
],
|
||||
"eval/runner/systems/baselines.ts": [
|
||||
" /** The last request timed out, lost its connection after it went out or was answered 2xx without a message; Particle may have billed it, so the cell never sends it again. */",
|
||||
" * (`compatDeadlineMs`), and one that times out, loses its connection after it went out or is answered 2xx without a",
|
||||
" * message (`compatMayHaveBilled`) is a `reader_error` that is never sent again, because Particle may have billed it. Each attempt is recorded, the failed ones with null response"
|
||||
],
|
||||
"eval/workload-suites/bench-infra.ts": [
|
||||
"import { COMPAT_PROVIDERS, CompatHttpError, ParticleCreditExhausted, compatMayHaveBilled, compatRequest, compatSend, type CompatRequest, type CompatResponse } from '../runner/providers/openai-compatible.ts';",
|
||||
" * not Bun's 300-second fetch timeout. A request Particle may have billed (`compatMayHaveBilled`: it timed out, lost its",
|
||||
" * connection after it went out, was answered 504, which the harness proxy sends when its upstream request failed after",
|
||||
" * it went out, or was answered 2xx without a message) is never sent again (issue #282).",
|
||||
" const retry = !compatMayHaveBilled(error) && (http ? http.retryable && attempt < 5 : !budget && attempt < 4);"
|
||||
],
|
||||
"scripts/particle-canary.ts": [
|
||||
"import { CompatHttpError, assertMetered, compatBaseUrl, compatMayHaveBilled, compatRequest, compatSend, isLoopbackUrl, type CanarySampling, type CompatLogprob, type CompatRequest } from '../eval/runner/providers/openai-compatible.ts';",
|
||||
"/**",
|
||||
" * Send the canary once per prompt (up to 3 attempts on a 429 or 5xx, honoring a capped Retry-After). A request Particle",
|
||||
" * may have billed (`compatMayHaveBilled`: a 504, or a 2xx without a message) is never sent again (issue #282). A budget",
|
||||
" * stop ends it after its receipt.",
|
||||
" */",
|
||||
" if (!(e instanceof CompatHttpError && e.retryable) || compatMayHaveBilled(e) || attempt === ATTEMPTS - 1) break;"
|
||||
]
|
||||
},
|
||||
"removed": {
|
||||
"eval/runner/cat40/loop.ts": [
|
||||
"/** A provider's non-2xx answer, after backoff for retryable statuses. */",
|
||||
" * Particle runs only: the run ended on a turn that timed out, or lost its connection after it went out",
|
||||
" * (`compatMayHaveBilled`). Particle may have billed that turn, so a caller never runs the question again.",
|
||||
" * recorded, and a 2xx that is not JSON or has no `choices[0].message` is retried as a server error. A `compat` request",
|
||||
" * is sent with `compatDeadlineMs` instead of Bun's 300-second fetch timeout, and a 504 (a gateway's or the lease",
|
||||
" last = new ProviderError(502, `${compat} answered ${res.status} ${parsed ? 'without choices[0].message' : 'with a body that is not JSON'}: ${text.slice(0, 300)}`);",
|
||||
" onAttempt?.({ status: 'error', response: json, error: last.message, ms: Date.now() - t, retry_after_ms: null });",
|
||||
" if (attempt < 5) await new Promise(r => setTimeout(r, Math.min(60_000, 2000 * 2 ** attempt) + Math.random() * 1000));",
|
||||
" continue;"
|
||||
],
|
||||
"eval/runner/harness-metering-proxy.ts": [
|
||||
"import { carriesKeyInCleartext, COMPAT_PROVIDERS } from './providers/openai-compatible.ts';",
|
||||
" const message = `metering proxy could not reach ${provider}: ${(error as Error).message}`;",
|
||||
" return finish(errorResponse(502, 'mpw_proxy_upstream_unreachable', message), { ...settle(null, 0, message), error: message });",
|
||||
" return finish(errorResponse(502, 'mpw_proxy_upstream_unreachable', message), { ...settle(null, upstream.status, message), error: message });"
|
||||
],
|
||||
"eval/runner/providers/openai-compatible.ts": [
|
||||
"/** A non-2xx answer that is not a budget stop. */",
|
||||
" * A request the provider may have billed: it timed out (`compatTimedOut`) or its connection was lost after it went out",
|
||||
" * (`compatConnectionLost`). A caller never sends it again, with or without a metering proxy in front.",
|
||||
" return compatTimedOut(e) || compatConnectionLost(e);",
|
||||
" * decides any retry. A 2xx answer without `choices[0].message` is a server error carrying the usage it reported,",
|
||||
" * never an empty answer. `timeoutMs` (300 s unless given) is the only deadline: Bun's own 300-second fetch timeout is",
|
||||
" * off, so a longer deadline holds.",
|
||||
" catch { throw new CompatHttpError({ provider: req.provider, status: res.status, kind: 'server', retryable: true, retryAfterMs: null, body: `response body is not JSON: ${text.slice(0, 200)}` }); }",
|
||||
" if (!Array.isArray(choices) || !choices[0]?.message) throw new CompatHttpError({ provider: req.provider, status: res.status, kind: 'server', retryable: true, retryAfterMs: null, body: `response has no choices[0].message: ${text.slice(0, 200)}` }, parsed.usage);"
|
||||
],
|
||||
"eval/runner/q1/cell.ts": [
|
||||
" /** `final`: a try that must not be sent again (an OpenAI-compatible request that timed out or lost its connection, which the provider may have billed). */"
|
||||
],
|
||||
"eval/runner/systems/baselines.ts": [
|
||||
" /** The last request timed out or lost its connection after it went out; Particle may have billed it, so the cell never sends it again. */",
|
||||
" * (`compatDeadlineMs`), and one that times out or loses its connection after it",
|
||||
" * went out (`compatMayHaveBilled`) is a `reader_error` that is never sent again, because Particle may have billed it. Each attempt is recorded, the failed ones with null response"
|
||||
],
|
||||
"eval/workload-suites/bench-infra.ts": [
|
||||
"import { COMPAT_PROVIDERS, CompatHttpError, ParticleCreditExhausted, compatRequest, compatSend, type CompatRequest, type CompatResponse } from '../runner/providers/openai-compatible.ts';",
|
||||
" * not Bun's 300-second fetch timeout, and a request that times out is never sent again.",
|
||||
" const timedOut = error.name === 'TimeoutError' || error.name === 'AbortError';",
|
||||
" const retry = http ? http.retryable && attempt < 5 : !budget && !timedOut && attempt < 4;"
|
||||
],
|
||||
"scripts/particle-canary.ts": [
|
||||
"import { CompatHttpError, assertMetered, compatBaseUrl, compatRequest, compatSend, isLoopbackUrl, type CanarySampling, type CompatLogprob, type CompatRequest } from '../eval/runner/providers/openai-compatible.ts';",
|
||||
"/** Send the canary once per prompt (up to 3 attempts on a 429 or 5xx, honoring a capped Retry-After). A budget stop ends it after its receipt. */",
|
||||
" if (!(e instanceof CompatHttpError && e.retryable) || attempt === ATTEMPTS - 1) break;"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/remote-identity-vectors.json': ['test/bin-context-windows-slug.test.ts', 'test/gstack-slug-parity.test.ts', 'test/remote-identity-vectors.test.ts'],
|
||||
'test/fixtures/review-depth/**': ['test/review-dispatch-compare.test.ts'],
|
||||
'test/fixtures/review-ledger/**': ['test/review-dispatch.test.ts'],
|
||||
'test/fixtures/review-risk/gbrain-evals-289.json': ['test/review-risk-routing.test.ts'],
|
||||
'test/fixtures/review-design-lite-reports-ci-36633323521.json': ['test/review-finalization-budget.test.ts'],
|
||||
'test/fixtures/review-gate-integrity-opus-reports.json': ['test/gate-integrity-templates.test.ts'],
|
||||
'test/fixtures/session-update-heartbeat.sh': ['test/session-update-heartbeat.test.ts'],
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* GST-850: the allowlisted docs paths (lib/review-allowlist.ts) read whole inputs in linear time, and every pattern that decides
|
||||
* a risk class or an allowlisted path is in DEPTH_RULES (docs/review-policy.md#allowlisted-docs-paths).
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { localeTextOnly, presentationalSvg } from '../lib/review-allowlist';
|
||||
import { matchRisk } from '../lib/review-risk';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const N = 10_000;
|
||||
const fast = (f: () => unknown) => { const t = performance.now(); f(); return performance.now() - t; };
|
||||
|
||||
describe('linear time on hostile input', () => {
|
||||
// Value: protects=gstack-review-plan and the queue verdict never hang on a diff (the R51 resource regex did at about 50
|
||||
// backslashes); fails_when=any scanner or risk pattern backtracks on a 10k-character input; seam=presentationalSvg,
|
||||
// localeTextOnly and matchRisk
|
||||
const svgInputs = ['\\'.repeat(N), '<'.repeat(N), '<g '.repeat(N), '&'.repeat(N), '<svg xmlns="' + '\\'.repeat(N), '<svg ' + ' '.repeat(N) + 'x="' + '"'.repeat(N),
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">' + '<g>'.repeat(N) + '</g>'.repeat(N) + '</svg>', '</g' + ' '.repeat(N) + '>', '<!--' + '-'.repeat(N), '<svg fill="' + 'url('.repeat(N) + '"/>'];
|
||||
test.each(svgInputs.map((s, n) => [n, s]))('SVG input %i finishes under 100 ms', (_n, s) => {
|
||||
expect(fast(() => presentationalSvg(s as string))).toBeLessThan(100);
|
||||
});
|
||||
const formats: [string, string, string][] = [
|
||||
['locales/en.json', '"k": "', '",'], ['locales/en.arb', '"k": "', '",'], ['locales/en.yml', 'k: "', '"'], ['locales/en.yml', "k: '", "'"], ['locales/fr.po', 'msgstr "', '"'],
|
||||
['locales/en.strings', '"k" = "', '";'], ['locales/fr.xlf', '<target>', '</target>'], ['locales/en.ftl', 'k = ', ''], ['locales/en.properties', 'k=', ''],
|
||||
];
|
||||
test.each(formats)('%s with 10k backslashes, quotes, braces and percent signs finishes under 100 ms', (p, open, close) => {
|
||||
for (const body of ['\\'.repeat(N), '\\'.repeat(N) + 'x', "'".repeat(N), '{'.repeat(N), '%1'.repeat(N / 2), '%' + '0'.repeat(N), '{{'.repeat(N / 2), '$t('.repeat(N / 3)]) {
|
||||
const line = open + body + close;
|
||||
expect(fast(() => localeTextOnly(p, [open + 'x' + close], [line]))).toBeLessThan(100);
|
||||
expect(fast(() => localeTextOnly(p, [line], [line]))).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
test('the risk patterns finish under 100 ms on 10k-character lines', () => {
|
||||
for (const body of ['fetch(' + "'".repeat(N), 'import {' + ' exec'.repeat(N / 5), 'path.join(' + 'a'.repeat(N), 'import ' + 'a '.repeat(N / 2) + 'from', 'require(' + ' '.repeat(N),
|
||||
' '.repeat(N) + '} = require(', "'..".repeat(N / 3), 'x'.repeat(N) + 'Usage', 'tar ' + '-'.repeat(N), 'import {'.repeat(N / 8)]) {
|
||||
expect(fast(() => matchRisk({ codePaths: ['src/a.ts'], added: { 'src/a.ts': [body] }, removed: { 'src/a.ts': [body] }, scopeAuth: false }))).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the rules hashes cover every new pattern', () => {
|
||||
// Value: protects=a receipt or queue verdict notices an allowlist or risk-pattern edit; fails_when=an edit to an SVG element,
|
||||
// a locale format, a security pattern or a money word leaves DEPTH_RULES unchanged; seam=DEPTH_RULES over ALLOWLIST_RULES and RISK_RULES
|
||||
test.each([
|
||||
['review-allowlist.ts', "'foreignObject'", "'clipPath', 'foreignObject'", "'clipPath'"],
|
||||
['review-allowlist.ts', "'properties'];", "'properties', 'ini'];", null],
|
||||
['review-risk.ts', "'billable', 'billed'", "'billable', 'billed', 'tariff'", null],
|
||||
['review-risk.ts', 'extract-zip|unzipper', 'extract-zip|unzipper|jszip', null],
|
||||
])('editing %s (%s) changes DEPTH_RULES', (file, _what, to, from) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-rules-'));
|
||||
try {
|
||||
for (const f of ['review-depth.ts', 'review-risk.ts', 'review-allowlist.ts']) fs.copyFileSync(path.join(ROOT, 'lib', f), path.join(dir, f));
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
|
||||
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
|
||||
const hash = () => {
|
||||
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
const before = hash();
|
||||
const src = fs.readFileSync(path.join(dir, file), 'utf8');
|
||||
const needle = from ?? _what;
|
||||
expect(src).toContain(needle);
|
||||
fs.writeFileSync(path.join(dir, file), src.replace(needle, to));
|
||||
expect(before).toMatch(/^[0-9a-f]{16}$/);
|
||||
expect(hash()).not.toBe(before);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
@@ -326,6 +326,7 @@ describe('review depth: diff commands and the rules hash', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8');
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/review-risk.ts'), path.join(dir, 'review-risk.ts'));
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/review-allowlist.ts'), path.join(dir, 'review-allowlist.ts'));
|
||||
const hashOf = (text: string) => {
|
||||
fs.writeFileSync(path.join(dir, 'review-depth.ts'), text);
|
||||
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
|
||||
@@ -424,6 +425,7 @@ describe('D6: the scoped second pass after a blocking fix (GSTA-152 PR B)', () =
|
||||
expect(src).toContain('SCOPED_RANGE_LINES = 150');
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/review-risk.ts'), path.join(dir, 'review-risk.ts'));
|
||||
fs.symlinkSync(path.join(ROOT, 'lib/review-allowlist.ts'), path.join(dir, 'review-allowlist.ts'));
|
||||
fs.writeFileSync(path.join(dir, 'review-depth.ts'), src.replace('SCOPED_RANGE_LINES = 150', 'SCOPED_RANGE_LINES = 151'));
|
||||
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
|
||||
const res = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('risk classes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI text and SVGs stay code (R51: the docs paths are deferred to an allowlist classifier)', () => {
|
||||
describe('UI component text, active SVGs and bound locale values stay code (R51; GST-850 allowlist)', () => {
|
||||
// Value: protects=a copy edit, a Vue binding or a namespaced SVG script never plans as docs; fails_when=any of them leaves the code
|
||||
// class or its lines skip the risk scan; seam=classifyDiff
|
||||
test.each([
|
||||
@@ -121,3 +121,170 @@ describe('the receipt records the tier and the rounds', () => {
|
||||
expect(receiptSummaryLine(legacy)).not.toContain('rounds=');
|
||||
});
|
||||
});
|
||||
|
||||
const SVG_PATH = 'docs/evals/chart.svg';
|
||||
const CHART = ['<?xml version="1.0" encoding="UTF-8"?>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100" role="img" aria-label="recall by arm">', ' <title>Recall by arm</title>',
|
||||
' <defs><linearGradient id="g"><stop offset="0" stop-color="#4a90d9"/><stop offset="1" stop-color="#1c5fa8"/></linearGradient></defs>', ' <g font-family="sans-serif" font-size="10">',
|
||||
' <rect x="10" y="20" width="40" height="70" fill="url(#g)" style="opacity: 0.9"/>', ' <path d="M10 90 H190" stroke="#333" stroke-width="1"/>', ' <text x="30" y="15" text-anchor="middle">33/41 & up</text>', ' <use href="#g"/>', ' </g>', '</svg>', ''];
|
||||
const classOf = (p: string, added: string[], removed: string[] = [], created: string[] = []) =>
|
||||
classifyDiff({ paths: [p], created, gateTagged: 0, added: { [p]: added }, removed: { [p]: removed }, perPath: [{ path: p, lines: added.length + removed.length, binary: false }] }).class;
|
||||
const svgClass = (lines: string[]) => classOf(SVG_PATH, lines, [], [SVG_PATH]);
|
||||
const swap = (from: string, to: string) => CHART.map(l => l.replace(from, to));
|
||||
|
||||
describe('GST-850: allowlisted docs paths (a new presentational SVG, a locale value edit)', () => {
|
||||
// Value: protects=a write-up with one clean chart, or a translation fix, gets one reader; fails_when=the allowlist rejects a clean
|
||||
// chart or locale value, or the batched specialist drops a write-up that publishes numbers; seam=classifyDiff and planReview
|
||||
test('a new chart SVG built from the allowlist is docs, and a write-up with one clean chart gets one reader', () => {
|
||||
expect(svgClass(CHART)).toBe('docs');
|
||||
expect(classifyDiff({ paths: [SVG_PATH], created: [SVG_PATH], gateTagged: 0, added: { [SVG_PATH]: CHART }, removed: {} }).reasons).toContain('docs: 1 presentational SVG path(s)');
|
||||
const md = 'docs/evals/run.md';
|
||||
const added = { [md]: ['Recall rose to 33/41.'], [SVG_PATH]: CHART };
|
||||
const p = plan({ paths: [md, SVG_PATH], created: [SVG_PATH], added, removed: {}, perPath: [{ path: md, lines: 1, binary: false }, { path: SVG_PATH, lines: CHART.length, binary: false }], numbers: scanNumbers(added) });
|
||||
expect([p.depth, roles(p)]).toEqual(['light', ['outside-voice']]);
|
||||
});
|
||||
test('the batched specialist reads a write-up that publishes numbers, and leaves docs out when nothing is published', () => {
|
||||
const md = 'docs/evals/run.md', code = 'scripts/score.ts';
|
||||
const base = { paths: [code, md], added: { [code]: ['export const k = 1;'], [md]: ['Recall rose to 33/41.'] }, removed: {}, perPath: [{ path: code, lines: 1, binary: false }, { path: md, lines: 1, binary: false }] };
|
||||
const batched = (numbers: ReturnType<typeof scanNumbers>) => plan({ ...base, numbers }).dispatches.find(d => d.name === 'batched')!.exclude.join(' ');
|
||||
expect(batched(scanNumbers(base.added))).not.toContain(md);
|
||||
expect(batched(none)).toContain(md);
|
||||
});
|
||||
test.each([
|
||||
['json', 'locales/en.json', ' "save": "Save",', ' "save": "Save now",'],
|
||||
['arb', 'lib/l10n/app_en.arb', ' "greeting": "Hello {name}",', ' "greeting": "Hi there {name}",'],
|
||||
['yaml quoted', 'app/i18n/en.yml', ' save: "Save"', ' save: "Save now"'],
|
||||
['yaml plain', 'locales/en.yaml', ' cancel: Cancel', ' cancel: Cancel it'],
|
||||
['po', 'locale/fr/LC_MESSAGES/app.po', 'msgstr "Enregistrer %s"', 'msgstr "Sauvegarder %s"'],
|
||||
['strings', 'Resources/lang/en.lproj/Localizable.strings', '"save" = "Save";', '"save" = "Save now";'],
|
||||
['xliff', 'translations/messages.fr.xlf', ' <target>Enregistrer</target>', ' <target>Sauvegarder</target>'],
|
||||
['ftl', 'locales/en-US/main.ftl', 'welcome = Welcome, { $user }!', 'welcome = Hello, { $user }!'],
|
||||
['properties', 'src/main/resources/i18n/messages.properties', 'button.save=Save', 'button.save=Save now'],
|
||||
])('a %s value edit under a locale directory is docs', (_n, p, before, after) => {
|
||||
expect(classOf(p, [after], [before])).toBe('docs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-850: every bypass of the line-pattern docs paths stays code', () => {
|
||||
// Value: protects=active or bound content never plans as docs; fails_when=any bypass from GST-850 or a sibling spelling classes docs;
|
||||
// seam=classifyDiff over lib/review-allowlist.ts
|
||||
test.each([
|
||||
['namespaced <s:script>', ['<svg xmlns:s="http://www.w3.org/2000/svg"><s:script>window.x=42</s:script></svg>']],
|
||||
['a <script> element', swap('<title>', '<script>alert(1)</script><title>')],
|
||||
['an uppercase SCRIPT', swap('<title>', '<SCRIPT>alert(1)</SCRIPT><title>')],
|
||||
['a multi-line onload attribute', ['<svg xmlns="http://www.w3.org/2000/svg"', ' onload', ' ="alert(1)">', '</svg>']],
|
||||
['an onclick handler', swap('<rect ', '<rect onclick="alert(1)" ')],
|
||||
['a Vue :fill binding', swap('<rect ', '<rect :fill="leak(document.cookie)" ')],
|
||||
['a v-bind: binding', swap('<rect ', '<rect v-bind:fill="x" ')],
|
||||
['an Alpine x-bind: binding', swap('<rect ', '<rect x-bind:fill="x" ')],
|
||||
['an @click binding', swap('<rect ', '<rect @click="x()" ')],
|
||||
['mustache text', swap('33/41 & up', '{{ constructor.constructor("alert(1)")() }}')],
|
||||
['a foreignObject', swap('<title>', '<foreignObject><div>x</div></foreignObject><title>')],
|
||||
['an animate on href', swap('<use href="#g"/>', '<use href="#g"><animate attributeName="href" to="javascript:alert(1)"/></use>')],
|
||||
['a set element', swap('<use href="#g"/>', '<set attributeName="fill" to="red"/>')],
|
||||
['a doctype with an entity', ['<!DOCTYPE svg [<!ENTITY x "y">]>', ...CHART.slice(1)]],
|
||||
['an xlink:href', swap('<use href="#g"/>', '<use xlink:href="#g"/>')],
|
||||
['an outside use href', swap('<use href="#g"/>', '<use href="https://evil.example/x.svg#a"/>')],
|
||||
['an href off use', swap('<text x="30"', '<text href="#g" x="30"')],
|
||||
['a style url(', swap('style="opacity: 0.9"', 'style="fill: url(#g)"')],
|
||||
['a style @import', swap('style="opacity: 0.9"', 'style="@import url(x.css)"')],
|
||||
['an outside fill url', swap('fill="url(#g)"', 'fill="url(https://evil.example/x)"')],
|
||||
['a CSS-escaped url', swap('fill="url(#g)"', 'fill="\\75rl(https://evil.example/x)"')],
|
||||
['an xml-stylesheet instruction', ['<?xml-stylesheet href="https://evil.example/x.css"?>', ...CHART.slice(1)]],
|
||||
['a CDATA section', swap('33/41 & up', '<![CDATA[<script>x</script>]]>')],
|
||||
['an image element', swap('<title>', '<image href="https://evil.example/x.png"/><title>')],
|
||||
['a style element', swap('<title>', '<style>@import url(x.css);</style><title>')],
|
||||
['another namespace', swap('xmlns="http://www.w3.org/2000/svg"', 'xmlns="http://www.w3.org/1999/xhtml"')],
|
||||
['a numeric entity in an attribute', swap('fill="url(#g)"', 'fill="url(https://evil.example/x)"')],
|
||||
['an unclosed root', CHART.slice(0, -2)],
|
||||
['two roots', [...CHART, '<svg xmlns="http://www.w3.org/2000/svg"></svg>']],
|
||||
['text after the root', [...CHART, 'trailing']],
|
||||
])('a new SVG with %s is code', (_n, lines) => {
|
||||
expect(svgClass(lines as string[])).toBe('code');
|
||||
});
|
||||
test('an edited SVG is code even when both sides are clean', () => {
|
||||
expect(classOf(SVG_PATH, CHART, swap('33/41', '30/41'))).toBe('code');
|
||||
expect(classOf(SVG_PATH, CHART)).toBe('code');
|
||||
});
|
||||
test.each([
|
||||
['i18n .ts', 'src/i18n/en.ts', " save: 'Save',", " save: 'Save now',"],
|
||||
['string-concat HTML', 'src/App.tsx', "const html = '<h1>' + title + '</h1>';", "const html = '<h1>' + leak() + '</h1>';"],
|
||||
['an edit inside an existing <script>', 'src/App.vue', ' const label = "Save";', ' const label = "Save now";'],
|
||||
['a Vue :title binding', 'src/App.vue', '<div :title="user.name">Profile</div>', '<div :title="leak(document.cookie)">Profile</div>'],
|
||||
['a markup value', 'locales/en.json', ' "save": "Save",', ' "save": "<img src=x onerror=alert(1)>",'],
|
||||
['a changed placeholder', 'locales/en.json', ' "hi": "Hi {name}",', ' "hi": "Hi {user.password}",'],
|
||||
['a new mustache expression', 'locales/en.json', ' "hi": "Hi",', ' "hi": "Hi {{constructor.constructor(1)()}}",'],
|
||||
['a vue-i18n linked message', 'locales/en.json', ' "hi": "Hi",', ' "hi": "Hi @:secret.key",'],
|
||||
['an i18next nesting', 'locales/en.json', ' "hi": "Hi",', ' "hi": "Hi $t(secret)",'],
|
||||
['a renamed key', 'locales/en.json', ' "save": "Save",', ' "store": "Save",'],
|
||||
['an escaped character', 'locales/en.json', ' "save": "Save",', ' "save": "\\u003cscript\\u003e",'],
|
||||
['a script URL', 'locales/en.yml', ' link: "https://example.com"', ' link: "javascript:alert(1)"'],
|
||||
['a locale file outside a locale directory', 'src/en.json', ' "save": "Save",', ' "save": "Save now",'],
|
||||
['a locale file under config/', 'config/locales/en.yml', ' save: "Save"', ' save: "Save now"'],
|
||||
])('%s is code', (_n, p, before, after) => {
|
||||
expect(classOf(p, [after], [before])).toBe('code');
|
||||
});
|
||||
test('an added key and the backtracking input are code, and the input finishes fast', () => {
|
||||
expect(classOf('locales/en.json', [' "extra": "Extra",'])).toBe('code');
|
||||
const t = performance.now();
|
||||
expect(classOf('locales/en.yml', [' save: "' + '\\'.repeat(10_000) + 'x'], [' save: "Save"'])).toBe('code');
|
||||
expect(classOf('locales/en.yml', [' save: "' + '\\'.repeat(9_999) + '"'], [' save: "Save"'])).toBe('code');
|
||||
expect(performance.now() - t).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-850: security reaches outbound URLs, archives, path traversal and every child_process spelling', () => {
|
||||
// Value: protects=an SSRF, archive-extraction, traversal or shell-exec diff reaches the full panel; fails_when=a spelling of any of
|
||||
// them reads light, or a literal URL, a spawnSync import or a tarball create raises a class; seam=matchRisk
|
||||
test.each([
|
||||
"import { exec } from 'node:child_process';", 'import { execSync as run } from "child_process";', "import { spawn, spawnSync } from 'node:child_process';",
|
||||
"import cp from 'node:child_process';", "import * as cp from 'child_process';", "import cp, { spawnSync } from 'child_process';",
|
||||
"const { exec } = require('child_process');", "const cp = require('node:child_process');", "const cp = await import('node:child_process');",
|
||||
'exec(cmd, cb);', 'cp.exec(cmd);',
|
||||
'const res = await fetch(url);', "await fetch('https://' + host + '/v1');", 'https.get(target, cb);', 'await axios.get(req.query.u);', 'const body = await got(url).text();',
|
||||
'r = requests.get(url, timeout=5)', 'urllib.request.urlopen(target)', 'const next = new URL(req.query.next, base);', "import { request } from 'undici';",
|
||||
"const res = await fetch('https://x.example', { redirect: 'follow' });", 'maxRedirects: 10,', 'r = httpx.get("https://x.example", follow_redirects=True)', 'const p = process.env.HTTPS_PROXY;', 'const agent = new HttpsProxyAgent(p);',
|
||||
"const ALLOWED_HOSTS = ['api.example.com'];", 'hostAllowlist.push(h);',
|
||||
'await tar.x({ file, cwd: dest });', 'new AdmZip(buf).extractAllTo(dir, true);', "import yauzl from 'yauzl';", "import extract from 'extract-zip';", 'zipfile.ZipFile(p).extractall(d)',
|
||||
'tarfile.open(p).extractall(d)', 'tar -xzf "$TGZ" -C "$DIR"', 'unzip -o "$ZIP" -d "$DIR"', 'fs.writeFileSync(path.join(root, entry.path), data);', 'const dest = path.resolve(out, req.params.name);',
|
||||
"if (rel.startsWith('..')) throw new Error('escape');", "if (parts.includes('..')) return;",
|
||||
])('%s is security', line => {
|
||||
expect(risk(['src/net.ts'], { 'src/net.ts': [line] })).toEqual(['security']);
|
||||
});
|
||||
test('a named import split over lines is security, from either side of the diff', () => {
|
||||
const split = ['import {', ' spawnSync,', ' exec,', "} from 'node:child_process';"];
|
||||
expect(risk(['src/run.ts'], { 'src/run.ts': split })).toEqual(['security']);
|
||||
expect([...matchRisk({ codePaths: ['src/run.ts'], added: {}, removed: { 'src/run.ts': split }, scopeAuth: false }).keys()]).toEqual(['security']);
|
||||
expect(risk(['src/run.ts'], { 'src/run.ts': ['import {', ' spawnSync,', "} from 'node:child_process';"] })).toEqual([]);
|
||||
});
|
||||
test('literal URLs, spawnSync imports, tarball creation and parent-dir joins are not security', () => {
|
||||
for (const line of ["const r = await fetch('https://api.github.com/repos');", "const u = new URL('https://example.com');", 'const here = new URL(import.meta.url);',
|
||||
"import { spawnSync } from 'node:child_process';", "import type { ExecOptions } from 'node:child_process';", 'tar -czf out.tgz dir', "const root = path.join(import.meta.dir, '..');",
|
||||
'const m = re.exec(line);', "ENV.fetch('HOME')", 'git fetch origin main'])
|
||||
expect(risk(['src/run.ts'], { 'src/run.ts': [line] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GST-850: money reaches metering, charge and credit code', () => {
|
||||
// Value: protects=a billing-client or metering diff gets the money reader; fails_when=gbrain-evals #289 reads required=none again,
|
||||
// or costume, a CLI usage line or a proxy with no billing names money; seam=matchRisk and targetedClasses
|
||||
test('gbrain-evals #289 (metering proxy and particle billing clients) reads money, each code path on its own', () => {
|
||||
const x = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/review-risk/gbrain-evals-289.json'), 'utf8'));
|
||||
expect(targetedClasses({ paths: x.paths, added: x.added, removed: x.removed, scope: { SCOPE_AUTH: 'false' }, gateTagged: 0 }).classes).toEqual(['money']);
|
||||
expect(classifyDiff({ paths: x.paths, added: x.added, removed: x.removed, gateTagged: 0 }).risk).toContain('money');
|
||||
const code = Object.keys({ ...x.added, ...x.removed });
|
||||
expect(code.length).toBe(7);
|
||||
for (const p of code) expect(matchRisk({ codePaths: [p], added: { [p]: x.added[p] ?? [] }, removed: { [p]: x.removed[p] ?? [] }, scopeAuth: false }).has('money')).toBe(true);
|
||||
});
|
||||
test('metering, charge and credit words and pairs name money; costume, CLI usage and a plain proxy do not', () => {
|
||||
expect(risk(['eval/runner/harness-metering-proxy.ts'])).toEqual(['money']);
|
||||
expect(risk(['lib/usage-tokens.ts'])).toEqual(['money']);
|
||||
for (const line of ['if (compatMayHaveBilled(e)) break;', 'const left = credits - spent;', 'await meter.record(n);', 'const usd = res.usage.output_tokens * rate;',
|
||||
'const billingProxy = startProxy();', 'export class CostClient {', 'const billable = true;'])
|
||||
expect(risk(['src/run.ts'], { 'src/run.ts': [line] })).toEqual(['money']);
|
||||
expect(risk(['src/costume.ts'])).toEqual([]);
|
||||
expect(risk(['src/cli/usage.ts'], { 'src/cli/usage.ts': ['export function printUsage() { console.log(USAGE); }'] })).toEqual([]);
|
||||
expect(risk(['src/proxy.ts'], { 'src/proxy.ts': ['const target = proxyTarget(host);'] })).toEqual([]);
|
||||
expect(risk(['src/geometry.ts'], { 'src/geometry.ts': ['const diameter = 2 * r; // a parameter'] })).toEqual([]);
|
||||
expect(classifyDiff({ paths: ['docs/usage.md'], gateTagged: 0, added: { 'docs/usage.md': ['Usage: gstack run'] }, removed: {} }).risk).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user