Skip to content
Draft
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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ jobs:
os: [ubuntu-latest, macos-latest]
node: ['22', '26']
runs-on: ${{ matrix.os }}
timeout-minutes: 30
# macOS Node 26 runs take 22-25 minutes (2026-11-12); 30 left one queue hiccup between a green
# suite and a cancelled one. A hung test is caught by its own timeout well inside this.
timeout-minutes: 40
steps:
# Actions pinned to commit SHAs, not tags: a tag can be silently repointed by the action
# owner (the trivy-action / tj-actions compromise class). The trailing comment names the
Expand Down
20 changes: 19 additions & 1 deletion admin/test/unit-routes-offbox-record-page.test.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
// Records a normalised offbox page entry and appends it to the page store (admin/routes/offbox.mjs recordPage).
import { test } from 'node:test';
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { recordPage } from '../routes/offbox.mjs';

// recordPage appends to the page store. Unscoped, every call below lands in the checkout's own
// .claude/store/offbox-pages.jsonl, which bin/test/sidecar-paths.test.mjs then reads as a store
// written with nothing versioning it.
const TMP = mkdtempSync(join(tmpdir(), 'cw-offbox-record-page-'));
let saved;

before(() => {
saved = process.env.CW_OFFBOX_PAGE_STORE;
process.env.CW_OFFBOX_PAGE_STORE = join(TMP, 'pages.jsonl');
});
after(() => {
if (saved === undefined) delete process.env.CW_OFFBOX_PAGE_STORE; else process.env.CW_OFFBOX_PAGE_STORE = saved;
rmSync(TMP, { recursive: true, force: true });
});

test('normalises a valid ok state with all fields present', () => {
const at = '2024-01-01T00:00:00.000Z';
const body = {
Expand Down
2 changes: 1 addition & 1 deletion bin/commit-phase.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,7 @@ function replay({ onto, ref, gitDir, key, check }) {
}
const tree = git(['write-tree'], { env, allowFail: true });
if (typeof tree !== 'string' || !/^[0-9a-f]{40}$/.test(tree)) return giveUp('write-tree failed during replay — nothing landed.');
const rawMsg = execFileSync('git', ['log', '-1', '--format=%B', c], { cwd: repo(), encoding: 'utf8' });
const rawMsg = git(['log', '-1', '--format=%B', c], { raw: true });
const { text: msg, removed } = stripForbiddenTrailers(rawMsg);
if (removed) console.error(`commit-phase: stripped ${removed} Co-Authored-By trailer(s) from ${c.slice(0, 7)} during replay.`);
const rebuilt = buildCommit([tree, '-p', parent], { input: msg, env });
Expand Down
12 changes: 4 additions & 8 deletions bin/disclosure-review.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,16 @@
// --out <dir> output dir (default $CW_DISCLOSURE_OUT or ./reports/disclosure-<stamp>)
// Exit 0 always on a completed run; 1 on bad input.

import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
import { readFileSync, mkdirSync } from 'node:fs';
import { join, basename } from 'node:path';
import { nowISO } from '../lib/clock.mjs';
import { judge } from '../lib/judge.mjs';
import { classify, priorityKey } from '../lib/judge-taxonomy.mjs';
import { isMainModule } from '../lib/is-main.mjs';
import { writeAtomic } from '../monitor/lockfile.mjs';

export const BUCKETS = Object.freeze(['confirmed', 'dismissed', 'undetermined']);

function writeAtomic(path, text) {
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, text);
renameSync(tmp, path);
}

async function mapPool(items, n, fn) {
const out = new Array(items.length);
let next = 0;
Expand Down Expand Up @@ -184,4 +180,4 @@ async function main() {
process.exit(0);
}

if (import.meta.url === `file://${process.argv[1]}`) main();
if (isMainModule(import.meta.url)) main();
3 changes: 2 additions & 1 deletion bin/init.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
privateDir, registryPathFor, authStorePath, annotationsPathFor, gateExemptionsPathFor, stateRootFor, STATE_PATHS,
stubAllowlistPathFor, imageAcceptancePathFor, ownerMapPathFor, programWorklistPathFor,
bolaManifestDirFor, craProductsPathFor, credentialScopePathFor, configCorrectnessLedgerPathFor,
disregardedWarningsPathFor,
disregardedWarningsPathFor, releaseSourcesPathFor,
} from '../monitor/store-paths.mjs';
import { loadRegistry, validateRegistry, validateAgainstSchema } from '../monitor/registry.mjs';

Expand All @@ -45,6 +45,7 @@ export const OPTIONAL_RECORDS = [
{ env: 'CW_DISREGARDED_WARNINGS', at: disregardedWarningsPathFor, absent: 'no warning has been set aside', example: 'monitor/disregarded-warnings.example.json' },
{ env: 'CW_PRODUCTS', at: craProductsPathFor, absent: 'CRA tools refuse with "not configured"', example: 'cra/products.example.json' },
{ env: 'CW_BOLA_MANIFEST_DIR', at: bolaManifestDirFor, absent: 'the BOLA lane has no manifests to run', example: 'manifests/bola/example.json' },
{ env: 'CW_RELEASE_SOURCES', at: releaseSourcesPathFor, absent: 'release-report --fetch has nothing to fetch', example: 'monitor/releases.example.json' },
];

export function parseInitArgs(argv) {
Expand Down
6 changes: 6 additions & 0 deletions bin/lib/sandbox-net.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ function main() {
b.kill('SIGKILL');
};
b.on('error', (e) => { refused = refused || `${opts.bwrap[0]}: ${e.code || e.message}`; say(`sandbox-net: ${refused}`); process.exitCode = NOT_RUN; });
// A refusal SIGKILLs bwrap, and a read or write already in flight on these pipes then fails with
// ECONNRESET or EPIPE. Unhandled, that crashes this helper with exit 1, and a caller reads 1 as the
// lane's own failure instead of NOT_RUN. The close handler below gives the verdict, so a pipe error
// after a kill is expected; any other pipe error is a refusal of its own.
const pipeError = (fd) => (e) => { if (e.code !== 'ECONNRESET' && e.code !== 'EPIPE') refuse(`bwrap fd ${fd}: ${e.code || e.message}`); };
for (const i of [3, 4, 5]) if (b.stdio[i]) b.stdio[i].on('error', pipeError(i));
if (data) b.stdio[5].end(resolvConf(readOr('/etc/resolv.conf')));
let info = '';
b.stdio[3].on('data', (d) => {
Expand Down
4 changes: 2 additions & 2 deletions bin/release-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { houseCss } from '../lib/house-css.mjs';
import { MARK_ICON } from '../lib/brand-tokens.mjs';
import { validateAgainstSchema } from '../lib/json-schema.mjs';
import { writeAtomic } from '../monitor/lockfile.mjs';
import { privateDir, reportsRootFor } from '../monitor/store-paths.mjs';
import { releaseSourcesPathFor, reportsRootFor } from '../monitor/store-paths.mjs';
import { kevFreshness, kevSet, epssFor, loadJSON, craRoot } from '../cra/lib.mjs';
import { loadIndex } from './advisory-index.mjs';
import { baseScore, band, versionOf } from './cvss.mjs';
Expand All @@ -52,7 +52,7 @@ const HOUR = 3600_000;
const SSH_TIMEOUT_MS = 60_000;

const releasesDir = () => resolve(process.env.CW_RELEASES_DIR || join(homedir(), '.commitwork', 'releases'));
const sourcesPath = () => resolve(process.env.CW_RELEASE_SOURCES || join(privateDir(CW), 'releases.json'));
const sourcesPath = () => releaseSourcesPathFor(CW);
const kevPath = () => resolve(process.env.CW_KEV || join(craRoot(), 'monitor', 'data', 'kev.json'));
const epssPath = () => resolve(process.env.CW_EPSS || join(craRoot(), 'monitor', 'data', 'epss.json'));

Expand Down
22 changes: 21 additions & 1 deletion bin/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { safeSpawnSync } from '../lib/win-spawn.mjs'; // npm/scoop/gem are .cmd
import { resolvePinnedTool } from '../lib/cobolwork-resolve.mjs';
import { verifyReleaseSignature } from '../lib/release-signing.mjs';
import { recordProvenance } from '../lib/tool-provenance.mjs';
import { unzip } from './cwe-graph.mjs';

const HERE = dirname(fileURLToPath(import.meta.url));
// The same override the panel's scanner routes read; both read at call time.
Expand Down Expand Up @@ -106,9 +107,22 @@ const releaseBase = () => process.env.CW_SETUP_RELEASE_BASE || 'https://github.c
export const releaseUrl = (spec, a) => `${releaseBase()}/${spec.repo}/releases/download/${spec.tag}/${a.asset}`;
const releaseBinDir = () => process.env.CW_SETUP_BIN_DIR || join(homedir(), '.local', 'bin');
export const LICENSE_FILE = /(^|\/)(LICEN[CS]E|COPYING|NOTICE)([._-][^/]*)?$/i;
// fact: GNU tar does not read a zip, bsdtar does / GNU tar is the tar on Linux and the one Git for Windows puts first on PATH under Git Bash, so a .zip pin installed only where bsdtar answered to `tar` (expiry: never, prev: broken)
/** The entries of a zip as [{ name, data }], or null when the bytes are not a zip this reader handles. */
function zipEntries(buf) {
try { return unzip(buf); } catch { return null; }
}
/** Copy an archive's licence files to dest; false when it holds none or they will not extract. */
function copyLicenses(archive, dest, tmp) {
const list = spawnSync('tar', ['-tf', archive], { encoding: 'utf8' });
if (list.status !== 0) {
// A backslash is a separator on Windows, so a name carrying one is not taken as a file name.
const held = (zipEntries(readFileSync(archive)) || []).filter((e) => LICENSE_FILE.test(e.name) && !e.name.includes('\\'));
if (!held.length) return false;
mkdirSync(dest, { recursive: true });
for (const e of held) writeFileSync(join(dest, e.name.split('/').pop()), e.data);
return true;
}
const names = list.status === 0 ? list.stdout.split('\n').filter((n) => LICENSE_FILE.test(n)) : [];
if (!names.length || spawnSync('tar', ['-xf', archive, '-C', tmp, ...names], { stdio: 'ignore' }).status !== 0) return false;
mkdirSync(dest, { recursive: true });
Expand Down Expand Up @@ -485,7 +499,13 @@ async function installRelease(row) {
// verb, and the format is detected from the bytes rather than assumed from a flag.
writeFileSync(join(tmp, a.asset), buf);
const x = spawnSync('tar', ['-xf', join(tmp, a.asset), '-C', tmp, bin], { stdio: 'ignore' });
if (x.status !== 0 || !existsSync(join(tmp, bin))) { console.log(red(` ✗ ${row.name}: ${bin} not found in ${a.asset}`)); return false; }
let extracted = x.status === 0 && existsSync(join(tmp, bin));
if (!extracted) {
// Where `tar` is GNU tar a zip is unreadable to it: the member is read from the verified bytes instead.
const member = (zipEntries(buf) || []).find((e) => e.name === bin);
if (member) { writeFileSync(join(tmp, bin), member.data); extracted = true; }
}
if (!extracted) { console.log(red(` ✗ ${row.name}: ${bin} not found in ${a.asset}`)); return false; }
const lic = process.env.CW_SETUP_LICENSE_DIR;
if (lic && !copyLicenses(join(tmp, a.asset), join(lic, row.name), tmp)) { console.log(red(` ✗ ${row.name}: no licence file in ${a.asset}`)); return false; }
const dir = releaseBinDir();
Expand Down
58 changes: 41 additions & 17 deletions bin/test/daily-run.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ if (n === 1) out.notActioned.push({ findingId: '0123456789abcdef', reason: 'low-
console.log(JSON.stringify({ is_error: false, subtype: 'success', structured_output: out, total_cost_usd: 0.25, usage: { input_tokens: 100, cache_read_input_tokens: 50, output_tokens: 10 }, modelUsage: { 'claude-test': { outputTokens: 10 } } }));
`;

/**
* Snapshot process.env and put it back IN PLACE after the test. Reassigning process.env (`process.env =
* saved`) swaps the live environment for a plain object; from Node 26 child_process no longer reads that
* object when no `env` option is passed, so later tests that put stubs on PATH spawn against the real
* environment and the stub never runs.
*/
function restoreEnvAfter(t) {
const saved = { ...process.env };
t.after(() => {
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
Object.assign(process.env, saved);
});
}

function fakeVeld() {
const todos = [];
const server = createServer((req, res) => {
Expand Down Expand Up @@ -75,8 +89,7 @@ test('a scheduled run writes a validated report after one refusal, files the tod
writeFileSync(skill, 'skill text');
const veld = await fakeVeld();
t.after(() => veld.server.close());
const saved = { ...process.env };
t.after(() => { process.env = saved; });
restoreEnvAfter(t);
Object.assign(process.env, {
CW_REGISTRY: fx.registry, CW_DAILY_CONFIG: fx.configPath, CW_CLAUDE_BIN: join(bin, 'claude'), CW_DAILY_SKILL: skill,
CW_NOW: '2026-10-02T01:00:00Z', CW_VELD_URL: veld.url, CW_VELD_USER: 'tester', VELD_API_KEY: 'test-key',
Expand Down Expand Up @@ -123,25 +136,27 @@ test('a scheduled run writes a validated report after one refusal, files the tod
});

test('a model that never answers within the schema leaves a failed run record and no report', async (t) => {
// The stubs drain stdin before answering. A stub that exits first races the prompt write, and the
// run then fails on EPIPE instead of on the missing schema.
// Arrange. The stubs drain stdin before answering. A stub that exits first races the prompt write,
// and the run then fails on EPIPE instead of on the missing schema.
const fx = dailyFixture({ previousRows: {}, currentRows: { sastSemgrep: [rows.exec] } });
const bin = join(fx.root, 'bin');
mkdirSync(bin);
writeFileSync(join(bin, 'claude'), `#!/bin/sh\ncat >/dev/null\necho x >> ${join(fx.root, 'calls')}\necho '{"is_error":true,"subtype":"error_max_turns","result":"no"}'\n`); chmodSync(join(bin, 'claude'), 0o755);
writeFileSync(join(bin, 'osascript'), `#!/bin/sh\necho "$2" >> ${join(fx.root, 'notices')}\n`); chmodSync(join(bin, 'osascript'), 0o755);
writeFileSync(join(fx.root, 'daily.md'), 'skill');
const saved = { ...process.env };
t.after(() => { process.env = saved; });
restoreEnvAfter(t);
Object.assign(process.env, { CW_REGISTRY: fx.registry, CW_DAILY_CONFIG: fx.configPath, CW_CLAUDE_BIN: join(bin, 'claude'), CW_DAILY_SKILL: join(fx.root, 'daily.md'), PATH: `${bin}:${process.env.PATH}` });
const calls = () => readFileSync(join(fx.root, 'calls'), 'utf8').trim().split('\n').length;
const notices = () => readFileSync(join(fx.root, 'notices'), 'utf8').trim().split('\n').length;

// Act and assert, per pass: the first run fails, the next skips, --force retries.
assert.equal(await main(['--area', AREA, '--no-todos']), 1);
const daily = join(fx.areaOut, 'daily');
assert.ok(!existsSync(join(daily, 'sweep-20261002000000.json')));
const run = JSON.parse(readFileSync(join(daily, 'sweep-20261002000000.run.json'), 'utf8'));
assert.equal(run.ok, false);
assert.match(run.attempts[0].error, /no structured output/);
const calls = () => readFileSync(join(fx.root, 'calls'), 'utf8').trim().split('\n').length;
const notices = () => readFileSync(join(fx.root, 'notices'), 'utf8').trim().split('\n').length;
assert.equal(notices(), 1, 'the failure notifies');
assert.equal(calls(), 1, 'the failed attempt is not retried within the run when claude itself errored');
assert.equal(await main(['--area', AREA, '--no-todos']), 0, 'the next pass skips a batch whose model run failed');
assert.equal(calls(), 1);
Expand All @@ -158,8 +173,7 @@ test('a run the area lock is held against does nothing, and leaves the holder it
writeFileSync(join(bin, 'claude'), `#!/bin/sh\ncat >/dev/null\necho x >> ${join(fx.root, 'calls')}\necho '{"is_error":true,"subtype":"error_max_turns","result":"no"}'\n`); chmodSync(join(bin, 'claude'), 0o755);
writeFileSync(join(bin, 'osascript'), '#!/bin/sh\n'); chmodSync(join(bin, 'osascript'), 0o755);
writeFileSync(join(fx.root, 'daily.md'), 'skill');
const saved = { ...process.env };
t.after(() => { process.env = saved; });
restoreEnvAfter(t);
Object.assign(process.env, { CW_REGISTRY: fx.registry, CW_DAILY_CONFIG: fx.configPath, CW_CLAUDE_BIN: join(bin, 'claude'), CW_DAILY_SKILL: join(fx.root, 'daily.md'), PATH: `${bin}:${process.env.PATH}` });
const held = acquireLock(join(fx.areaOut, 'daily', '.lock'), { label: 'another daily run' });
assert.ok(held.ok);
Expand All @@ -174,8 +188,7 @@ test('a run the area lock is held against does nothing, and leaves the holder it

test('a refused suggestions file leaves no run record, and one for a reported batch is refused', async (t) => {
const fx = dailyFixture({ previousRows: {}, currentRows: { sastSemgrep: [rows.exec] } });
const saved = { ...process.env };
t.after(() => { process.env = saved; });
restoreEnvAfter(t);
Object.assign(process.env, { CW_REGISTRY: fx.registry, CW_DAILY_CONFIG: fx.configPath });
const bad = join(fx.root, 'bad.json');
writeFileSync(bad, JSON.stringify({ headline: 'h', suggestions: [], notActioned: [] }));
Expand All @@ -192,6 +205,7 @@ test('a refused suggestions file leaves no run record, and one for a reported ba
});

test('a failure notifies when it appears, when it changes, and once a day while it lasts', (t) => {
// Arrange
const root = mkdtempSync(join(tmpdir(), 'cw-daily-notice-'));
const bin = join(root, 'bin');
mkdirSync(bin);
Expand All @@ -201,12 +215,22 @@ test('a failure notifies when it appears, when it changes, and once a day while
process.env.PATH = `${bin}:${saved}`;
const state = join(root, 'state.json');
const t0 = new Date('2026-10-03T00:00:00Z');
assert.equal(noticeFailure(state, 'ar', 'skill not committed', t0), true);
assert.equal(noticeFailure(state, 'ar', 'skill not committed', new Date(t0.getTime() + 30 * 60_000)), false);
assert.equal(noticeFailure(state, 'ar', 'anchor moved', new Date(t0.getTime() + 60 * 60_000)), true);
assert.equal(noticeFailure(state, 'ar', 'anchor moved', new Date(t0.getTime() + 25 * 3_600_000)), true);
const at = (ms) => new Date(t0.getTime() + ms);

// Act
const appears = noticeFailure(state, 'ar', 'skill not committed', t0);
const sameWithinADay = noticeFailure(state, 'ar', 'skill not committed', at(30 * 60_000));
const changes = noticeFailure(state, 'ar', 'anchor moved', at(60 * 60_000));
const sameNextDay = noticeFailure(state, 'ar', 'anchor moved', at(25 * 3_600_000));
clearFailure(state, 'ar');
assert.equal(noticeFailure(state, 'ar', 'anchor moved', new Date(t0.getTime() + 26 * 3_600_000)), true);
const afterClear = noticeFailure(state, 'ar', 'anchor moved', at(26 * 3_600_000));

// Assert
assert.equal(appears, true);
assert.equal(sameWithinADay, false);
assert.equal(changes, true);
assert.equal(sameNextDay, true);
assert.equal(afterClear, true);
assert.equal(readFileSync(join(root, 'notices'), 'utf8').trim().split('\n').length, 4);
});

Expand Down
Loading
Loading