Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions .github/workflows/sdk-cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ jobs:
run: npm ci

- name: Security Vulnerability Audit
working-directory: ${{ env.CLI_DIR }}
# GT-657: runs from the repository root, not ${{ env.CLI_DIR }}. The scope
# is identical — one root lockfile, audited whole — but the gate reads
# .harness/config/npm-audit-exceptions.json, and a guard should be invoked
# from the tree it is reasoning about rather than resolve its way out of a
# workspace directory.
# Scope, made explicit (GT-568): there is a single root lockfile, so this
# audits the ENTIRE monorepo dependency tree — every workspace's prod and
# dev deps — not just the CLI's. That is deliberate: it is stricter than a
Expand All @@ -117,7 +121,25 @@ jobs:
# patched version with a targeted `overrides` entry in the root
# package.json (see js-yaml / brace-expansion / protobufjs) rather than
# regenerating package-lock.json wholesale.
run: npm audit --audit-level=high
#
# GT-657: that advice has a third case it could not express — an advisory
# a targeted override CANNOT reach, because the consumer pins its
# dependency EXACTLY and every published release of it pins a vulnerable
# one. `npm audit --audit-level=high` then leaves the job permanently red,
# which is the failure mode GT-622 was opened to remove: a check that is
# always red trains reviewers to discount red, and the next real advisory
# arrives into a job nobody reads. The gate below keeps the same threshold
# and adds exactly one thing — such an advisory must be NAMED, with the
# path it arrives by and what was checked upstream, and the guard turns red
# again the day the exception stops matching a real advisory.
run: node .harness/scripts/ci/63-validate-npm-audit-gate.mjs --verbose

- name: The audit gate's own fixtures
# A gate whose exception list can swallow anything is a green button. These
# fixtures are what say it cannot: an undeclared advisory is red, a
# declaration for a different path or id covers nothing, and a declaration
# whose advisory is gone fails as stale.
run: node --test .harness/scripts/ci/63-validate-npm-audit-gate.test.mjs

# ============================================
# JOB 3: Lint and Type Check
Expand Down
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,11 @@ rag-backfill-receipt.json
# worktree.
/src/packages/mcp-server/__capability-operations.entry.ts
/src/packages/mcp-server/__capability-operations.json

# macOS Finder metadata. Written by the OS anywhere a directory is opened in
# Finder, so it reappears without anyone editing a file — three copies were
# sitting untracked in the root, src/ and src/packages/ on 2026-08-08. They are
# ignored rather than merely deleted because deleting is not durable: the next
# Finder window recreates them, and an untracked file in the root is what
# `03-validate-root-cleanliness` fails on.
.DS_Store
22 changes: 22 additions & 0 deletions .harness/config/npm-audit-exceptions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"$comment": "GT-657. A high/critical advisory belongs here ONLY when no upstream fix exists and no `overrides` entry can reach it. Both conditions must be measured before an entry is added, and `noUpstreamFix` must record what was actually checked, not what was assumed. 63-validate-npm-audit-gate fails when an entry stops matching a real advisory, so an exception cannot outlive the hole it excuses.",
"exceptions": [
{
"id": "GHSA-pm4m-ph32-ghv5",
"package": "js-yaml",
"path": "node_modules/@nestjs/swagger/node_modules/js-yaml",
"declaredAt": "2026-08-08",
"noUpstreamFix": "Every published @nestjs/swagger release pins js-yaml to an EXACT version and all three are vulnerable: 11.4.4 -> 4.1.1 and 11.4.5 -> 4.3.0 (CVE-2026-59870, vulnerable 4.0.0-4.3.0), 11.4.6 -> 5.2.1 (this advisory, vulnerable 5.0.0-5.2.1). 11.4.6 is the latest stable; 12.0.0 exists only as alpha. npm overrides do not rewrite that nested exact spec, measured four ways: a top-level `js-yaml` override, a scoped `@nestjs/swagger: { js-yaml }` override, both with the unrelated nested override objects removed to test whether they blocked the cascade (they did not), and through `npm install --package-lock-only` as well as a real `npm install`. Every route produced the same tree.",
"reason": "The advisory is a denial of service in js-yaml's parser, triggered by parsing adversarial YAML. @nestjs/swagger uses js-yaml to SERIALISE the OpenAPI document this service generates from its own decorators; core-api never parses caller-supplied YAML through it. The exposure is a parser this repository never points at untrusted input. This is an acceptance of a specific hole with a known shape, not of the package: the moment @nestjs/swagger ships a patched pin, this entry stops matching and the gate turns red asking for its removal.",
"doNotRevertTheSwaggerBump": "Trivy reports this as a NEW high alert on any PR that changes package-lock.json, because the advisory id at that position changed. It is not a regression and reverting @nestjs/swagger 11.4.6 -> 11.4.4 makes things WORSE, measured: 11.4.4 pins js-yaml 4.1.1, which carries THREE advisories (GHSA-52cp-r559-cp3m HIGH, GHSA-5p4m-2wfm-xmqj HIGH, GHSA-h67p-54hq-rp68 moderate), while 11.4.6 pins 5.2.1, which carries ONE. The bump removes two high advisories and leaves one. This note exists because the revert was attempted during GT-657 on a first reading of Trivy's output and had to be undone; npm's row count and Trivy's per-PR 'new alerts' view can both make the better tree look like the worse one."
},
{
"id": "via:js-yaml",
"package": "@nestjs/swagger",
"path": "node_modules/@nestjs/swagger",
"declaredAt": "2026-08-08",
"noUpstreamFix": "Not an advisory against @nestjs/swagger itself. npm reports the parent separately as `depends on vulnerable versions of js-yaml`, so it clears exactly when GHSA-pm4m-ph32-ghv5 above clears, and never independently.",
"reason": "The derived half of the row above. Declared separately because npm reports it as its own high-severity row, and an exception that silently swallowed the parent would hide a future advisory that genuinely lands on @nestjs/swagger itself."
}
]
}
62 changes: 61 additions & 1 deletion .harness/scripts/ci/03-validate-root-cleanliness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";

// GT-578: this guard reads exactly one directory. If `process.cwd()` is not the
// repository root — the shape that broke 12/21/31/33/34 in GT-556 — readdirSync
Expand Down Expand Up @@ -82,6 +83,34 @@ const explicitlyDeniedDirectories = new Map([
]
]);

/**
* The subset of `names` that git ignores, as a Set.
*
* `git check-ignore --stdin` exits 0 when it ignored something, 1 when it
* ignored nothing, and 128 when it could not answer at all — a missing git, a
* directory that is not a work tree. Only the first two are answers. Anything
* else falls back to the EMPTY set, which is the strict reading: every entry
* stays under the taxonomy. The failure of an optional query must never widen
* what a guard forgives, and it says so out loud rather than degrading quietly.
*/
function gitIgnoredRootEntries(cwd, names) {
if (names.length === 0) return new Set();
try {
const out = execFileSync("git", ["check-ignore", "--stdin"], {
cwd, input: `${names.join("\n")}\n`, encoding: "utf8", stdio: ["pipe", "pipe", "ignore"],
});
return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
} catch (error) {
// Exit 1 is the real answer "none of these are ignored", not a failure.
if (error.status === 1) return new Set();
console.warn(
`⚠️ Root Cleanliness: could not ask git which entries are ignored (${error.status ?? error.code}).\n` +
` Continuing with NOTHING treated as ignored, so every root entry is checked.`,
);
return new Set();
}
}

const failures = [];

const rootEntries = fs.readdirSync(root, { withFileTypes: true });
Expand All @@ -101,7 +130,35 @@ if (missingAnchors.length > 0) {
process.exit(1);
}

// What git ignores is, by definition, not in the repository — and this guard
// governs what the repository root HOLDS, not what an operating system leaves
// lying in the directory. Before this, a single `.DS_Store` — written by Finder
// merely for opening the folder, tracked by nothing, recreated the moment it is
// deleted — failed the guard on a developer's machine while CI, which always
// checks out fresh, never saw it. A guard that is red for a reason no commit can
// fix is the failure mode GT-622 was opened to remove.
//
// This does NOT weaken the taxonomy: an unauthorized file that git does not
// ignore still fails, which is every file a commit could actually introduce.
const ignoredNames = gitIgnoredRootEntries(root, rootEntries.map((e) => e.name));

// Paranoia in the direction that matters. This set SUBTRACTS from what is
// checked, so a bogus answer from git would hollow the guard out silently. The
// anchors are tracked by construction, so an answer claiming they are ignored is
// not an unusual repository — it is a broken query, and must stop the run.
const ignoredAnchors = ANCHORS.filter((a) => ignoredNames.has(a));
if (ignoredAnchors.length > 0) {
console.error(
`❌ Root Cleanliness Validation cannot run: git reports ${ignoredAnchors.join(", ")} as ignored.\n` +
`These are tracked by construction, so the ignore query is wrong, and trusting it would\n` +
`subtract real entries from the check and report a pass over them.`,
);
process.exit(1);
}

for (const entry of rootEntries) {
// Ignored by git: not in the repository, so not this guard's business.
if (ignoredNames.has(entry.name)) continue;
// `.git` is only in `allowedDirectories`, which is correct in a normal
// checkout but not in a `git worktree`: there, `.git` at the root is a
// plain text file (`gitdir: <path>`) redirecting to the real one, so it
Expand Down Expand Up @@ -135,4 +192,7 @@ if (failures.length > 0) {
process.exit(1);
}

console.log(`✓ Root Cleanliness Validation Passed (${rootEntries.length} root entr(ies) inspected)`);
console.log(
`✓ Root Cleanliness Validation Passed (${rootEntries.length} root entr(ies) read, ` +
`${ignoredNames.size} ignored by git, ${rootEntries.length - ignoredNames.size} checked against the taxonomy)`,
);
Loading
Loading