@bongos/core 1.19.340 → 1.19.342
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +45 -20
- package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +162 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +1 -1
- package/docs/recipes/redteam-patrol.md +57 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/control-manifest.js +10 -3
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/push-path-brief.js +94 -0
- package/scripts/gds/redteam-patrol.js +78 -0
- package/scripts/gds/routine-schedule.js +231 -0
- package/scripts/gds/run-routine.js +14 -5
- package/src/module-api.js +5 -1
- package/tests/push_path_brief.mjs +90 -0
- package/tests/redteam_patrol.mjs +63 -0
- package/tests/routine_schedule.mjs +176 -0
- package/tests/run_routine.mjs +5 -2
|
@@ -24,6 +24,24 @@
|
|
|
24
24
|
// to /api/gds/security/reports. --canary proves the end-to-end pipe: plants a
|
|
25
25
|
// constructed token, asserts the scanner catches it, files a low "[canary]"
|
|
26
26
|
// report, and dispute-closes it so the queue stays clean.
|
|
27
|
+
//
|
|
28
|
+
// UNATTENDED RUNS (task 1003209). The scheduler is .github/workflows/redteam-patrol.yml
|
|
29
|
+
// — CI, not the always-on server, because all three checks read things only the
|
|
30
|
+
// REPO has: full git history, .github/workflows/, and a package-lock npm can audit
|
|
31
|
+
// (ADR 0188). A CI runner has no GDS session, so --file would otherwise hit
|
|
32
|
+
// cli-lib's interactive re-auth wall and print "run /builder-reauth" into a cron
|
|
33
|
+
// log. Instead --file preflights the session and, when there is none, follows the
|
|
34
|
+
// main-audit.js --require-roster doctrine: report LOUDLY rather than green-and-
|
|
35
|
+
// vacuous. The exit code says whether a finding was LOST, not whether findings
|
|
36
|
+
// exist — findings are advisory (ADR 0162) and a patrol that goes red merely for
|
|
37
|
+
// doing its job trains everyone to ignore it:
|
|
38
|
+
//
|
|
39
|
+
// ran + filed → 0
|
|
40
|
+
// ran, nothing to file → 0 (with or without a session)
|
|
41
|
+
// ran, has findings, cannot file → 2 — the one state where evidence evaporates
|
|
42
|
+
//
|
|
43
|
+
// Findings are ALSO appended to $GITHUB_STEP_SUMMARY when set, so a scheduled run
|
|
44
|
+
// is readable in the Actions UI without opening the log.
|
|
27
45
|
|
|
28
46
|
'use strict';
|
|
29
47
|
|
|
@@ -211,6 +229,45 @@ function buildClassReport(className, findings, notes = []) {
|
|
|
211
229
|
};
|
|
212
230
|
}
|
|
213
231
|
|
|
232
|
+
// Append markdown to the Actions job summary when running under GitHub Actions.
|
|
233
|
+
// Best-effort and inert everywhere else — a patrol run must never fail because a
|
|
234
|
+
// log surface was unavailable.
|
|
235
|
+
function stepSummary(md) {
|
|
236
|
+
const dest = process.env.GITHUB_STEP_SUMMARY;
|
|
237
|
+
if (!dest) return;
|
|
238
|
+
try { fs.appendFileSync(dest, `${md}\n`); } catch { /* never fatal */ }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Pure: what a completed sweep should DO with its findings, and the exit code
|
|
242
|
+
// that disposition deserves. Extracted so the honest-exit contract is a table a
|
|
243
|
+
// test can enumerate rather than branching buried in main().
|
|
244
|
+
//
|
|
245
|
+
// The load-bearing row is the last one. Findings are ADVISORY (ADR 0162), so a
|
|
246
|
+
// run that finds three things and files them is a SUCCESS — going red merely for
|
|
247
|
+
// doing its job is how a weekly lane becomes noise everyone filters. Red is
|
|
248
|
+
// reserved for the single state where evidence EVAPORATES: findings exist,
|
|
249
|
+
// filing was asked for, and it cannot happen.
|
|
250
|
+
function disposition({ findingClasses = 0, doFile = false, blocker = null } = {}) {
|
|
251
|
+
if (!findingClasses) return { action: 'clean', exitCode: 0 };
|
|
252
|
+
if (!doFile) return { action: 'dry-run', exitCode: 0 };
|
|
253
|
+
if (blocker) return { action: 'not-filed', exitCode: 2 };
|
|
254
|
+
return { action: 'file', exitCode: 0 };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Is there a GDS session this run could file with? Returns the reason it cannot,
|
|
258
|
+
// or null when filing is possible. Read directly (readSessionToken) rather than
|
|
259
|
+
// probing with apiCall, because apiCall's 401 path exits the process with
|
|
260
|
+
// interactive re-auth guidance — useless in a cron log and indistinguishable from
|
|
261
|
+
// a crash.
|
|
262
|
+
function filingBlocker() {
|
|
263
|
+
try {
|
|
264
|
+
const { readSessionToken } = require('./cli-lib.js');
|
|
265
|
+
return readSessionToken() ? null : 'no GDS session on this runner';
|
|
266
|
+
} catch (e) {
|
|
267
|
+
return `session unreadable: ${e && e.message}`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
214
271
|
async function fileReport(report) {
|
|
215
272
|
const { apiCall } = require('./cli-lib.js');
|
|
216
273
|
const r = await apiCall('POST', '/api/gds/security/reports', report);
|
|
@@ -283,10 +340,30 @@ async function main() {
|
|
|
283
340
|
const pins = actionPinCheck();
|
|
284
341
|
if (pins.findings.length) classes.push(buildClassReport('action-pins', pins.findings));
|
|
285
342
|
|
|
343
|
+
if (secrets.degraded) stepSummary(`> **DEGRADED** — ${secrets.degraded}`);
|
|
344
|
+
|
|
286
345
|
if (!classes.length) {
|
|
287
346
|
console.log('patrol: clean sweep — nothing to file.');
|
|
347
|
+
stepSummary('## red-team patrol\n\nClean sweep — no findings to file.');
|
|
288
348
|
return;
|
|
289
349
|
}
|
|
350
|
+
|
|
351
|
+
stepSummary(['## red-team patrol', ...classes.map((c) =>
|
|
352
|
+
`### ${c.title}\n\nseverity: **${c.severity}**\n\n${c.repro_steps_md}`)].join('\n\n'));
|
|
353
|
+
|
|
354
|
+
// Preflight the session ONCE, before the first POST: a half-filed sweep (class
|
|
355
|
+
// one lands, class two hits the auth wall and exits) is worse than not starting.
|
|
356
|
+
const blocker = doFile ? filingBlocker() : null;
|
|
357
|
+
const { action, exitCode } = disposition({ findingClasses: classes.length, doFile, blocker });
|
|
358
|
+
if (action === 'not-filed') {
|
|
359
|
+
console.error(`patrol: DID NOT FILE — ${blocker}. ${classes.length} finding class(es) above are NOT in the adjudication queue.`);
|
|
360
|
+
console.error('patrol: sign in on this runner (or set the scheduler\'s session secret) and re-run — see docs/recipes/redteam-patrol.md.');
|
|
361
|
+
for (const report of classes) console.error(` [${report.severity}] ${report.title}\n${report.repro_steps_md}`);
|
|
362
|
+
stepSummary(`\n> **NOT FILED** — ${blocker}. These findings exist only in this run's log.`);
|
|
363
|
+
process.exitCode = exitCode;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
290
367
|
for (const report of classes) {
|
|
291
368
|
if (doFile) {
|
|
292
369
|
const id = await fileReport(report);
|
|
@@ -305,4 +382,5 @@ if (require.main === module) {
|
|
|
305
382
|
module.exports = {
|
|
306
383
|
parseGitleaksReport, unpinnedUses, ageDays, mapSeverity, maxSeverity,
|
|
307
384
|
buildClassReport, lockedVersion, COOLDOWN_DAYS, YOUNG_PKG_DAYS,
|
|
385
|
+
stepSummary, filingBlocker, disposition,
|
|
308
386
|
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/routine-schedule.js — a declared cadence must have something that
|
|
3
|
+
// fires it (task 1003209, goal 1000079; docs/audits/ai-dev-redteam-followup.md §7).
|
|
4
|
+
//
|
|
5
|
+
// THE GAP THIS CLOSES. Goal 1000076 shipped the redteam-weekly patrol, wired it
|
|
6
|
+
// into the control manifest, and documented it — and then nothing ever ran it.
|
|
7
|
+
// The follow-up audit's §7 named all three halves of the failure:
|
|
8
|
+
//
|
|
9
|
+
// 1. no committed scheduler for .claude/scheduled-tasks/ at all;
|
|
10
|
+
// 2. the SKILL.md declared `mode` and `steps` but no cadence — "nothing for a
|
|
11
|
+
// scheduler to read even if one looked";
|
|
12
|
+
// 3. control-manifest.js called the patrol "wired" on a substring (the SKILL.md
|
|
13
|
+
// mentions its own scanner), so the manifest was GREEN while the patrol was
|
|
14
|
+
// dormant.
|
|
15
|
+
//
|
|
16
|
+
// (2) is now a real frontmatter field (run-routine.js parseRoutineFrontmatter),
|
|
17
|
+
// (1) is .github/workflows/redteam-patrol.yml, and THIS file is (3): the check
|
|
18
|
+
// that refuses the combination of a declared cadence and nothing firing it.
|
|
19
|
+
//
|
|
20
|
+
// WHY A DEDICATED CHECK RATHER THAN ANOTHER MANIFEST ROW. The manifest's
|
|
21
|
+
// predicates are deliberately shallow — file exists, one wiring substring. That
|
|
22
|
+
// shape is exactly what let the patrol read as wired while dormant, and this goal
|
|
23
|
+
// has now hit the same failure five times: an enforcement surface that NAMES a
|
|
24
|
+
// file goes VACUOUS, not red, when the thing it names moves or stops. So the
|
|
25
|
+
// predicate here is relational, not existential: it reads the cadence each
|
|
26
|
+
// routine declares, reads the cron each scheduler actually carries, and fails
|
|
27
|
+
// when the two disagree in EITHER direction —
|
|
28
|
+
//
|
|
29
|
+
// • a routine declares `cadence:` and no scheduler fires it → red
|
|
30
|
+
// • a scheduler fires a routine that no longer exists → red (rename rot)
|
|
31
|
+
// • the cron's real period differs from the declared cadence → red
|
|
32
|
+
//
|
|
33
|
+
// The third is the one a substring check can never catch: edit the cron from
|
|
34
|
+
// weekly to yearly and every "is it wired?" predicate stays green.
|
|
35
|
+
//
|
|
36
|
+
// TWO-REPO TOLERANCE. Scheduled routines are TOPOLOGY — `bongos init/upgrade`
|
|
37
|
+
// materializes .claude/scheduled-tasks/ into each instance (claude-materialize.js),
|
|
38
|
+
// but .github/workflows/ is the CORE repo's and does not travel. So an instance
|
|
39
|
+
// checkout legitimately has routines and no workflows directory. There, the
|
|
40
|
+
// unscheduled routines are reported as WARNINGS (the instance's operator owns its
|
|
41
|
+
// own scheduler); in a repo that HAS workflows, they are violations. That keeps
|
|
42
|
+
// the check honest in the core without red-building every instance.
|
|
43
|
+
|
|
44
|
+
'use strict';
|
|
45
|
+
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
|
|
49
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
50
|
+
const TASKS_DIR = path.join(ROOT, '.claude', 'scheduled-tasks');
|
|
51
|
+
const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows');
|
|
52
|
+
|
|
53
|
+
const { parseRoutineFrontmatter } = require('./run-routine.js');
|
|
54
|
+
|
|
55
|
+
// The cadences a routine may declare. The value is the period a cron expression
|
|
56
|
+
// must resolve to for the two to agree. Add a name here and cronCadence() below
|
|
57
|
+
// must be able to produce it, or the pair can never match.
|
|
58
|
+
const CADENCES = new Set(['hourly', 'daily', 'weekly', 'monthly']);
|
|
59
|
+
|
|
60
|
+
// Pure: classify a 5-field cron expression into the cadence it actually realizes.
|
|
61
|
+
// Returns null for anything this vocabulary cannot describe (step values, lists,
|
|
62
|
+
// ranges) — a cadence can then never "match" it, which is the safe direction: an
|
|
63
|
+
// exotic cron must be described some other way rather than silently accepted.
|
|
64
|
+
//
|
|
65
|
+
// min hour dom mon dow
|
|
66
|
+
// 17 6 * * 1 → weekly (a specific day-of-week)
|
|
67
|
+
// 17 6 * * * → daily (a specific hour, every day)
|
|
68
|
+
// 17 6 3 * * → monthly (a specific day-of-month)
|
|
69
|
+
// 17 * * * * → hourly (a specific minute, every hour)
|
|
70
|
+
function cronCadence(expr) {
|
|
71
|
+
const f = String(expr == null ? '' : expr).trim().split(/\s+/);
|
|
72
|
+
if (f.length !== 5) return null;
|
|
73
|
+
const [min, hour, dom, mon, dow] = f;
|
|
74
|
+
const fixed = (v) => /^\d+$/.test(v);
|
|
75
|
+
const any = (v) => v === '*';
|
|
76
|
+
if (!fixed(min)) return null; // every cadence below pins the minute
|
|
77
|
+
if (!any(mon)) return null; // a specific month is a yearly shape
|
|
78
|
+
if (any(hour)) return any(dom) && any(dow) ? 'hourly' : null;
|
|
79
|
+
if (!fixed(hour)) return null;
|
|
80
|
+
if (fixed(dow) && any(dom)) return 'weekly';
|
|
81
|
+
if (fixed(dom) && any(dow)) return 'monthly';
|
|
82
|
+
if (any(dom) && any(dow)) return 'daily';
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Pure: every cron expression a workflow file declares under `on: schedule:`.
|
|
87
|
+
// A focused subset of YAML (the repo has no js-yaml dep and the parser in
|
|
88
|
+
// run-routine.js is the same shape): the `cron:` keys are unambiguous enough to
|
|
89
|
+
// read directly, and a workflow that hides one behind an anchor would simply
|
|
90
|
+
// read as unscheduled — the safe direction.
|
|
91
|
+
function workflowCrons(yamlText) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const line of String(yamlText == null ? '' : yamlText).split('\n')) {
|
|
94
|
+
const m = /^\s*-?\s*cron:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/.exec(line);
|
|
95
|
+
if (m) out.push(m[1].trim());
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Pure: does `yamlText` actually invoke routine `name`? Either the canonical
|
|
101
|
+
// dispatcher form (`run-routine.js <name>`) or one of the routine's own step
|
|
102
|
+
// commands verbatim. Both are real invocations; a mere mention in prose is not,
|
|
103
|
+
// which is the whole point — the substring predicate this replaces was satisfied
|
|
104
|
+
// by a comment.
|
|
105
|
+
function invokesRoutine(yamlText, name, steps = []) {
|
|
106
|
+
const hay = String(yamlText == null ? '' : yamlText);
|
|
107
|
+
const dispatched = new RegExp(`run-routine\\.js\\s+(?:--\\S+\\s+)*${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`);
|
|
108
|
+
if (dispatched.test(hay)) return true;
|
|
109
|
+
return steps.some((s) => s.command && hay.includes(s.command));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Read every routine that declares a cadence, plus the ones that don't.
|
|
113
|
+
function readRoutines(dir = TASKS_DIR) {
|
|
114
|
+
const withCadence = [];
|
|
115
|
+
const withoutCadence = [];
|
|
116
|
+
let names = [];
|
|
117
|
+
try {
|
|
118
|
+
names = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
119
|
+
} catch (_) { return { withCadence, withoutCadence }; }
|
|
120
|
+
for (const name of names.sort()) {
|
|
121
|
+
let md;
|
|
122
|
+
try { md = fs.readFileSync(path.join(dir, name, 'SKILL.md'), 'utf8'); } catch (_) { continue; }
|
|
123
|
+
const { cadence, steps } = parseRoutineFrontmatter(md);
|
|
124
|
+
if (cadence) withCadence.push({ name, cadence, steps });
|
|
125
|
+
else withoutCadence.push({ name });
|
|
126
|
+
}
|
|
127
|
+
return { withCadence, withoutCadence };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readWorkflows(dir = WORKFLOWS_DIR) {
|
|
131
|
+
let files = [];
|
|
132
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')); }
|
|
133
|
+
catch (_) { return null; } // null = no workflows directory at all (instance repo)
|
|
134
|
+
return files.sort().map((f) => ({ file: f, text: fs.readFileSync(path.join(dir, f), 'utf8') }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Pure evaluation — exported for the test, which drives it with fixtures.
|
|
138
|
+
// `workflows === null` means "this checkout has no CI to schedule with", which
|
|
139
|
+
// downgrades unscheduled routines from violations to warnings (see the two-repo
|
|
140
|
+
// note in the header).
|
|
141
|
+
function evaluate({ withCadence, withoutCadence }, workflows) {
|
|
142
|
+
const violations = [];
|
|
143
|
+
const warnings = [];
|
|
144
|
+
const hasCI = workflows !== null;
|
|
145
|
+
const wf = workflows || [];
|
|
146
|
+
|
|
147
|
+
for (const r of withCadence) {
|
|
148
|
+
if (!CADENCES.has(r.cadence)) {
|
|
149
|
+
violations.push(`routine '${r.name}': unknown cadence "${r.cadence}" (want ${[...CADENCES].join('|')})`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const firing = wf.filter((w) => invokesRoutine(w.text, r.name, r.steps));
|
|
153
|
+
if (!firing.length) {
|
|
154
|
+
const msg = `routine '${r.name}' declares cadence: ${r.cadence} but NOTHING fires it — no scheduled workflow invokes it`;
|
|
155
|
+
(hasCI ? violations : warnings).push(msg);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// It is invoked somewhere. At least one of those invocations must be on a
|
|
159
|
+
// schedule whose real period is the declared cadence. A workflow_dispatch-only
|
|
160
|
+
// lane is a human running it — exactly the state §7 called "decorative".
|
|
161
|
+
const seen = [];
|
|
162
|
+
let matched = false;
|
|
163
|
+
for (const w of firing) {
|
|
164
|
+
for (const expr of workflowCrons(w.text)) {
|
|
165
|
+
const actual = cronCadence(expr);
|
|
166
|
+
seen.push(`${w.file}: "${expr}" → ${actual || 'unclassifiable'}`);
|
|
167
|
+
if (actual === r.cadence) matched = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!matched) {
|
|
171
|
+
violations.push(
|
|
172
|
+
seen.length
|
|
173
|
+
? `routine '${r.name}' declares cadence: ${r.cadence} but no scheduler fires it at that period — ${seen.join('; ')}`
|
|
174
|
+
: `routine '${r.name}' declares cadence: ${r.cadence} but ${firing.map((w) => w.file).join(', ')} carries no 'on: schedule:' cron (manual dispatch only)`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Rename rot, the other direction: a scheduler that dispatches a routine name
|
|
180
|
+
// no longer on disk. run-routine.js exits 2 on a missing SKILL.md, so this
|
|
181
|
+
// would be a weekly red job nobody watches; catch it at merge time instead.
|
|
182
|
+
// Deduped: a workflow legitimately names its own invocation more than once (the
|
|
183
|
+
// step itself plus the header comment explaining why it dispatches rather than
|
|
184
|
+
// calling the script), and one rename should read as one problem.
|
|
185
|
+
const known = new Set([...withCadence, ...withoutCadence].map((r) => r.name));
|
|
186
|
+
const dangling = new Set();
|
|
187
|
+
for (const w of wf) {
|
|
188
|
+
for (const m of w.text.matchAll(/run-routine\.js\s+(?:--\S+\s+)*([A-Za-z0-9_-]+)/g)) {
|
|
189
|
+
if (!known.has(m[1])) dangling.add(`${w.file} dispatches routine '${m[1]}', which has no .claude/scheduled-tasks/${m[1]}/SKILL.md`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
violations.push(...dangling);
|
|
193
|
+
|
|
194
|
+
for (const r of withoutCadence) {
|
|
195
|
+
warnings.push(`routine '${r.name}' declares no cadence: — it can only ever be run by hand`);
|
|
196
|
+
}
|
|
197
|
+
return { violations, warnings };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The fitness.js check.
|
|
201
|
+
function checkRoutineCadencesScheduled() {
|
|
202
|
+
const routines = readRoutines();
|
|
203
|
+
const workflows = readWorkflows();
|
|
204
|
+
const { violations, warnings } = evaluate(routines, workflows);
|
|
205
|
+
const scheduled = routines.withCadence.length - violations.filter((v) => v.startsWith("routine '")).length;
|
|
206
|
+
return {
|
|
207
|
+
name: 'a declared cron cadence has something that fires it (task 1003209)',
|
|
208
|
+
ok: violations.length === 0,
|
|
209
|
+
hardFail: violations.length > 0,
|
|
210
|
+
violations,
|
|
211
|
+
warnings,
|
|
212
|
+
note: violations.length
|
|
213
|
+
? 'a scheduled routine is declared but dormant — add/repair the scheduler, or drop the cadence: field and stop claiming it runs'
|
|
214
|
+
: `${scheduled}/${routines.withCadence.length} cadence-declaring routine(s) fired by a committed scheduler at the declared period; ${routines.withoutCadence.length} manual-only.`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function main() {
|
|
219
|
+
const r = checkRoutineCadencesScheduled();
|
|
220
|
+
for (const v of r.violations) console.error(`✗ ${v}`);
|
|
221
|
+
for (const w of r.warnings) console.warn(`⚠ ${w}`);
|
|
222
|
+
console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
|
|
223
|
+
process.exit(r.hardFail ? 1 : 0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (require.main === module) main();
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
checkRoutineCadencesScheduled, evaluate, cronCadence, workflowCrons,
|
|
230
|
+
invokesRoutine, readRoutines, readWorkflows, CADENCES,
|
|
231
|
+
};
|
|
@@ -47,12 +47,19 @@ function unquote(s) {
|
|
|
47
47
|
return t;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
// Pure: parse mode + steps[] from SKILL.md frontmatter. A focused YAML
|
|
51
|
-
// (no js-yaml dep): scalar `mode:`/`description:` and a `steps:`
|
|
52
|
-
// `- command: …` / `sink: …` items. Unknown keys are ignored. Exported + tested.
|
|
50
|
+
// Pure: parse mode + cadence + steps[] from SKILL.md frontmatter. A focused YAML
|
|
51
|
+
// subset (no js-yaml dep): scalar `mode:`/`cadence:`/`description:` and a `steps:`
|
|
52
|
+
// list of `- command: …` / `sink: …` items. Unknown keys are ignored. Exported + tested.
|
|
53
|
+
//
|
|
54
|
+
// `cadence:` (task 1003209) is the field the follow-up audit found missing — the
|
|
55
|
+
// routines declared HOW to run but never HOW OFTEN, so "there was nothing for a
|
|
56
|
+
// scheduler to read even if one looked." It is declarative only: run-routine.js
|
|
57
|
+
// still runs a routine exactly once per invocation. The cadence is the contract a
|
|
58
|
+
// committed scheduler must honour, and scripts/gds/routine-schedule.js is the check
|
|
59
|
+
// that refuses a cadence nothing fires.
|
|
53
60
|
function parseRoutineFrontmatter(skillMd) {
|
|
54
61
|
const norm = String(skillMd == null ? '' : skillMd).replace(/\r\n/g, '\n');
|
|
55
|
-
const empty = { mode: null, steps: [], description: null };
|
|
62
|
+
const empty = { mode: null, cadence: null, steps: [], description: null };
|
|
56
63
|
if (!norm.startsWith('---\n')) return empty;
|
|
57
64
|
const lines = norm.split('\n');
|
|
58
65
|
let close = -1;
|
|
@@ -62,6 +69,7 @@ function parseRoutineFrontmatter(skillMd) {
|
|
|
62
69
|
if (close === -1) return empty;
|
|
63
70
|
|
|
64
71
|
let mode = null;
|
|
72
|
+
let cadence = null;
|
|
65
73
|
let description = null;
|
|
66
74
|
const steps = [];
|
|
67
75
|
let inSteps = false;
|
|
@@ -96,12 +104,13 @@ function parseRoutineFrontmatter(skillMd) {
|
|
|
96
104
|
|
|
97
105
|
if (!inSteps && isTopLevel && topKv) {
|
|
98
106
|
if (topKv[1] === 'mode') mode = unquote(topKv[2]) || null;
|
|
107
|
+
else if (topKv[1] === 'cadence') cadence = unquote(topKv[2]) || null;
|
|
99
108
|
else if (topKv[1] === 'description') description = topKv[2].trim();
|
|
100
109
|
else if (topKv[1] === 'steps' && topKv[2].trim() === '') inSteps = true;
|
|
101
110
|
}
|
|
102
111
|
}
|
|
103
112
|
pushCur();
|
|
104
|
-
return { mode, steps, description };
|
|
113
|
+
return { mode, cadence, steps, description };
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
// Pure: split a repo-authored command string into argv, honoring simple quotes.
|
package/src/module-api.js
CHANGED
|
@@ -1110,8 +1110,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1110
1110
|
// landed since 1.19.338 with no explicit bump. run 32797325535. (task 1002620)
|
|
1111
1111
|
// 1.19.340 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1112
1112
|
// landed since 1.19.339 with no explicit bump. run 32798342738. (task 1002620)
|
|
1113
|
+
// 1.19.341 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1114
|
+
// landed since 1.19.340 with no explicit bump. run 32798507686. (task 1002620)
|
|
1115
|
+
// 1.19.342 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1116
|
+
// landed since 1.19.341 with no explicit bump. run 32799103427. (task 1002620)
|
|
1113
1117
|
// ---------------------------------------------------------------------------
|
|
1114
|
-
const CORE_VERSION = '1.19.
|
|
1118
|
+
const CORE_VERSION = '1.19.342'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1115
1119
|
|
|
1116
1120
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1117
1121
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// tests/push_path_brief.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1003259 / idea 1000838 — a dev-box agent, unable to see WHY its git probes were
|
|
4
|
+
// failing, asked the owner to mint a PAT and store it on the box. push-path-brief.js is
|
|
5
|
+
// the SessionStart line that states the credential model natively so that request never
|
|
6
|
+
// gets made again. These tests pin BOTH halves: the line fires on the server-publish
|
|
7
|
+
// path with the load-bearing phrases intact, and it is SILENT everywhere else — the
|
|
8
|
+
// silence is half the design (a line firing on every healthy laptop becomes wallpaper).
|
|
9
|
+
//
|
|
10
|
+
// DB-free + network-free: briefLine is pure, and the CLI cases force the path with
|
|
11
|
+
// OTB_PUSH_VIA_SERVER so pushVia() never spawns gh. Run: node tests/push_path_brief.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const brief = require('../scripts/gds/push-path-brief.js');
|
|
21
|
+
|
|
22
|
+
let passed = 0, failed = 0;
|
|
23
|
+
function t(name, fn) {
|
|
24
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
25
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---- the pure core -----------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
t('server path gets the brief, with every load-bearing phrase', () => {
|
|
31
|
+
const l = brief.briefLine('server');
|
|
32
|
+
assert.ok(typeof l === 'string' && l.length > 0, 'expected a line on the server path');
|
|
33
|
+
// Each phrase is one of the three facts the 2026-08-24 incident showed an agent
|
|
34
|
+
// cannot infer from inside a box. Losing any of them re-opens the gap.
|
|
35
|
+
for (const must of [
|
|
36
|
+
'NO GitHub push credential',
|
|
37
|
+
'BY DESIGN',
|
|
38
|
+
'/builder-ship',
|
|
39
|
+
'NEVER ask the owner for a PAT',
|
|
40
|
+
'publish-status',
|
|
41
|
+
]) {
|
|
42
|
+
assert.ok(l.includes(must), `line must state: ${must}`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
t('local path is SILENT — null, not an empty string', () => {
|
|
47
|
+
assert.equal(brief.briefLine('local'), null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
t('undetermined path stays silent (fail-open, exact vocabulary only)', () => {
|
|
51
|
+
assert.equal(brief.briefLine(null), null);
|
|
52
|
+
assert.equal(brief.briefLine(undefined), null);
|
|
53
|
+
assert.equal(brief.briefLine('SERVER'), null);
|
|
54
|
+
assert.equal(brief.briefLine('ci'), null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
t('the line is the exported static constant — nothing is interpolated', () => {
|
|
58
|
+
// Trust boundary: this string is injected into every box session's context. Pinning
|
|
59
|
+
// it to the constant proves no builder-authored text can ever ride along.
|
|
60
|
+
assert.equal(brief.briefLine('server'), brief.BRIEF);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---- the CLI, end-to-end through the pushVia() override -----------------------
|
|
64
|
+
|
|
65
|
+
const script = path.join(
|
|
66
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
67
|
+
'..', 'scripts', 'gds', 'push-path-brief.js',
|
|
68
|
+
);
|
|
69
|
+
function runCli(env) {
|
|
70
|
+
return spawnSync(process.execPath, [script, '--line'], {
|
|
71
|
+
env: { ...process.env, ...env },
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
timeout: 15000,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
t('--line on the forced server path prints the brief and exits 0', () => {
|
|
78
|
+
const r = runCli({ OTB_PUSH_VIA_SERVER: '1' });
|
|
79
|
+
assert.equal(r.status, 0, `expected exit 0, got ${r.status} (stderr: ${r.stderr})`);
|
|
80
|
+
assert.ok(r.stdout.includes('publish-status'), 'expected the brief on stdout');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
t('--line on the forced local path prints NOTHING and still exits 0', () => {
|
|
84
|
+
const r = runCli({ OTB_PUSH_VIA_SERVER: '0' });
|
|
85
|
+
assert.equal(r.status, 0, `expected exit 0, got ${r.status} (stderr: ${r.stderr})`);
|
|
86
|
+
assert.equal(r.stdout.trim(), '', 'the laptop case must be silent');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
console.log(`\npush_path_brief: ${passed} passed, ${failed} failed`);
|
|
90
|
+
process.exit(failed ? 1 : 0);
|
package/tests/redteam_patrol.mjs
CHANGED
|
@@ -65,3 +65,66 @@ test('buildClassReport caps the item list, counts the remainder, and takes max s
|
|
|
65
65
|
assert.match(r.repro_steps_md, /…and 3 more/);
|
|
66
66
|
assert.match(r.repro_steps_md, /Notes:/);
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Unattended-run behaviour (task 1003209): the patrol now runs on CI, which has
|
|
71
|
+
// no GDS session. These pin the two things that turns on — an exit code that
|
|
72
|
+
// means "a finding was LOST" rather than "a finding exists", and a job-summary
|
|
73
|
+
// surface so a scheduled run is readable without opening the log.
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
import fs from 'node:fs';
|
|
76
|
+
import os from 'node:os';
|
|
77
|
+
import { spawnSync } from 'node:child_process';
|
|
78
|
+
|
|
79
|
+
test('disposition: red is reserved for findings that could not be recorded', () => {
|
|
80
|
+
// A clean sweep is green even if filing was impossible — nothing was lost.
|
|
81
|
+
assert.deepEqual(p.disposition({ findingClasses: 0, doFile: true, blocker: 'no session' }),
|
|
82
|
+
{ action: 'clean', exitCode: 0 });
|
|
83
|
+
// A dry run prints; it never claims to have filed, so it never fails.
|
|
84
|
+
assert.deepEqual(p.disposition({ findingClasses: 3, doFile: false, blocker: null }),
|
|
85
|
+
{ action: 'dry-run', exitCode: 0 });
|
|
86
|
+
// Findings filed for adjudication = the patrol working as designed (ADR 0162).
|
|
87
|
+
// If this ever became a failure, a weekly red would train everyone to ignore it.
|
|
88
|
+
assert.deepEqual(p.disposition({ findingClasses: 3, doFile: true, blocker: null }),
|
|
89
|
+
{ action: 'file', exitCode: 0 });
|
|
90
|
+
// The ONE red state: findings exist, filing was asked for, and it cannot happen.
|
|
91
|
+
assert.deepEqual(p.disposition({ findingClasses: 3, doFile: true, blocker: 'no GDS session on this runner' }),
|
|
92
|
+
{ action: 'not-filed', exitCode: 2 });
|
|
93
|
+
assert.deepEqual(p.disposition(), { action: 'clean', exitCode: 0 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('stepSummary appends markdown for the Actions UI, and is inert off-CI', () => {
|
|
97
|
+
const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'patrol-')), 'summary.md');
|
|
98
|
+
const prev = process.env.GITHUB_STEP_SUMMARY;
|
|
99
|
+
try {
|
|
100
|
+
process.env.GITHUB_STEP_SUMMARY = f;
|
|
101
|
+
p.stepSummary('## one');
|
|
102
|
+
p.stepSummary('## two');
|
|
103
|
+
assert.equal(fs.readFileSync(f, 'utf8'), '## one\n## two\n');
|
|
104
|
+
delete process.env.GITHUB_STEP_SUMMARY;
|
|
105
|
+
p.stepSummary('## three'); // must not throw, must not write anywhere
|
|
106
|
+
assert.equal(fs.readFileSync(f, 'utf8'), '## one\n## two\n');
|
|
107
|
+
} finally {
|
|
108
|
+
if (prev === undefined) delete process.env.GITHUB_STEP_SUMMARY;
|
|
109
|
+
else process.env.GITHUB_STEP_SUMMARY = prev;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('filingBlocker reports a sessionless runner instead of exiting with re-auth guidance', () => {
|
|
114
|
+
// The regression guarded here: cli-lib's apiCall answers 401 by PRINTING
|
|
115
|
+
// "run /builder-reauth" and calling process.exit(2). In a cron log that is
|
|
116
|
+
// indistinguishable from a crash, and it kills the run before the findings are
|
|
117
|
+
// ever printed. A subprocess with an empty HOME is the only honest way to
|
|
118
|
+
// simulate a runner with no session file — the suite's own machine has one.
|
|
119
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'patrol-home-'));
|
|
120
|
+
const r = spawnSync(process.execPath, ['-e',
|
|
121
|
+
`const p = require(${JSON.stringify(path.join(ROOT, 'scripts', 'gds', 'redteam-patrol.js'))});
|
|
122
|
+
console.log(JSON.stringify({ blocker: p.filingBlocker() }));`,
|
|
123
|
+
], { env: { ...process.env, HOME: home, USERPROFILE: home }, encoding: 'utf8' });
|
|
124
|
+
assert.equal(r.status, 0, `expected a clean return, not an exit: ${r.stderr}`);
|
|
125
|
+
const out = JSON.parse(r.stdout.trim().split('\n').pop());
|
|
126
|
+
assert.equal(typeof out.blocker, 'string');
|
|
127
|
+
assert.match(out.blocker, /no GDS session|session unreadable/);
|
|
128
|
+
assert.doesNotMatch(r.stdout + r.stderr, /builder-reauth/,
|
|
129
|
+
'the sessionless path must not print interactive re-auth guidance into a cron log');
|
|
130
|
+
});
|