@bongos/core 1.19.340 → 1.19.341
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 +35 -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/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 +3 -1
- package/tests/redteam_patrol.mjs +63 -0
- package/tests/routine_schedule.mjs +176 -0
- package/tests/run_routine.mjs +5 -2
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.341",
|
|
6
|
+
"core_contract": "1.19.341",
|
|
7
|
+
"source_commit": "395ad8796967381d04b9f5ad9247da0cbf60d419",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-25T01:39
|
|
9
|
+
"built_at": "2026-08-25T01:41:39.768Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 326,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1485,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1833,
|
|
20
|
+
"tree_sha256": "29b405f9216626b6f363e10be67e90f2bde67fdd472151fc4eaf0a6c18384acd",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1419,10 +1419,15 @@
|
|
|
1419
1419
|
"mode": "0000644",
|
|
1420
1420
|
"sha256": "d59906a447f2459efdef6c3eb97cf8629813ec74776ead700017f02ef76aed9e"
|
|
1421
1421
|
},
|
|
1422
|
+
{
|
|
1423
|
+
"path": "docs/adr/0188-patrol-runs-on-ci-not-the-server.md",
|
|
1424
|
+
"mode": "0000644",
|
|
1425
|
+
"sha256": "4feacf7bc809cdff1a23a1ef5b57d5d28aa448b80bd1ad24fc70860c0fe6739f"
|
|
1426
|
+
},
|
|
1422
1427
|
{
|
|
1423
1428
|
"path": "docs/adr/README.md",
|
|
1424
1429
|
"mode": "0000644",
|
|
1425
|
-
"sha256": "
|
|
1430
|
+
"sha256": "4c736c015e635ed0b4e78445f35acbd8b84a14708634e5caf4a957357b1e22b9"
|
|
1426
1431
|
},
|
|
1427
1432
|
{
|
|
1428
1433
|
"path": "docs/api-reference.md",
|
|
@@ -1572,7 +1577,7 @@
|
|
|
1572
1577
|
{
|
|
1573
1578
|
"path": "docs/file-map.md",
|
|
1574
1579
|
"mode": "0000644",
|
|
1575
|
-
"sha256": "
|
|
1580
|
+
"sha256": "bbe32c152ba31a68953f318f9a94e010252e70a6e076d64d7dca820f5c19777b"
|
|
1576
1581
|
},
|
|
1577
1582
|
{
|
|
1578
1583
|
"path": "docs/handoff-template.md",
|
|
@@ -1772,7 +1777,7 @@
|
|
|
1772
1777
|
{
|
|
1773
1778
|
"path": "docs/recipes/redteam-patrol.md",
|
|
1774
1779
|
"mode": "0000644",
|
|
1775
|
-
"sha256": "
|
|
1780
|
+
"sha256": "656f30f568a56111ce6c2db8f3d61b0f9df3b168f41388b953a4552144579c94"
|
|
1776
1781
|
},
|
|
1777
1782
|
{
|
|
1778
1783
|
"path": "docs/recipes/search-index-ingestion.md",
|
|
@@ -4727,12 +4732,12 @@
|
|
|
4727
4732
|
{
|
|
4728
4733
|
"path": "package-lock.json",
|
|
4729
4734
|
"mode": "0000644",
|
|
4730
|
-
"sha256": "
|
|
4735
|
+
"sha256": "7c3952267fd38da7241791d8f5c4e7a2d8226d4cf8d30473fd6fe56ca000a4af"
|
|
4731
4736
|
},
|
|
4732
4737
|
{
|
|
4733
4738
|
"path": "package.json",
|
|
4734
4739
|
"mode": "0000644",
|
|
4735
|
-
"sha256": "
|
|
4740
|
+
"sha256": "d2ed11ab8e90eb8293778ce19fe921737158c0ab9d8063a9842383b1cfc59e94"
|
|
4736
4741
|
},
|
|
4737
4742
|
{
|
|
4738
4743
|
"path": "public-docs/index.html",
|
|
@@ -5262,7 +5267,7 @@
|
|
|
5262
5267
|
{
|
|
5263
5268
|
"path": "scripts/gds/control-manifest.js",
|
|
5264
5269
|
"mode": "0000644",
|
|
5265
|
-
"sha256": "
|
|
5270
|
+
"sha256": "6c9538a110e45a184531e3ddd7725a8307da823411ef71f0d0b575dd3ee9571b"
|
|
5266
5271
|
},
|
|
5267
5272
|
{
|
|
5268
5273
|
"path": "scripts/gds/copy-inventory.js",
|
|
@@ -5407,7 +5412,7 @@
|
|
|
5407
5412
|
{
|
|
5408
5413
|
"path": "scripts/gds/fitness.js",
|
|
5409
5414
|
"mode": "0000644",
|
|
5410
|
-
"sha256": "
|
|
5415
|
+
"sha256": "925006ed968209632b20677d05594158ebc27e65229d96a7d69a53baf93f6ed0"
|
|
5411
5416
|
},
|
|
5412
5417
|
{
|
|
5413
5418
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -5772,7 +5777,7 @@
|
|
|
5772
5777
|
{
|
|
5773
5778
|
"path": "scripts/gds/redteam-patrol.js",
|
|
5774
5779
|
"mode": "0000644",
|
|
5775
|
-
"sha256": "
|
|
5780
|
+
"sha256": "7460ee330a6bc53176a6ab83cb1019d50fe7d872d8a5bf31486e87edc86059af"
|
|
5776
5781
|
},
|
|
5777
5782
|
{
|
|
5778
5783
|
"path": "scripts/gds/regen-instance-docs.js",
|
|
@@ -5799,10 +5804,15 @@
|
|
|
5799
5804
|
"mode": "0000644",
|
|
5800
5805
|
"sha256": "084fa5c4da1e755fa0edde5ec770e5151a9f66a247bca222bc7a2366ccf61748"
|
|
5801
5806
|
},
|
|
5807
|
+
{
|
|
5808
|
+
"path": "scripts/gds/routine-schedule.js",
|
|
5809
|
+
"mode": "0000644",
|
|
5810
|
+
"sha256": "0d75c6b9be5a67738c91e55cda9084e10d7c64aa49c80ce9c0fa4219afcbd14c"
|
|
5811
|
+
},
|
|
5802
5812
|
{
|
|
5803
5813
|
"path": "scripts/gds/run-routine.js",
|
|
5804
5814
|
"mode": "0000644",
|
|
5805
|
-
"sha256": "
|
|
5815
|
+
"sha256": "8b7765f52f4d6e6559a47bbc911bf5edc4acea81a3765b8811be9fd07be8dfb7"
|
|
5806
5816
|
},
|
|
5807
5817
|
{
|
|
5808
5818
|
"path": "scripts/gds/run-unit-tests.js",
|
|
@@ -6557,7 +6567,7 @@
|
|
|
6557
6567
|
{
|
|
6558
6568
|
"path": "src/module-api.js",
|
|
6559
6569
|
"mode": "0000644",
|
|
6560
|
-
"sha256": "
|
|
6570
|
+
"sha256": "96c4cf1842a773983825fb0a8639b3e73eb0a3e7646a38781962530f072f6457"
|
|
6561
6571
|
},
|
|
6562
6572
|
{
|
|
6563
6573
|
"path": "src/module-loader/catalog.js",
|
|
@@ -8567,7 +8577,7 @@
|
|
|
8567
8577
|
{
|
|
8568
8578
|
"path": "tests/redteam_patrol.mjs",
|
|
8569
8579
|
"mode": "0000644",
|
|
8570
|
-
"sha256": "
|
|
8580
|
+
"sha256": "2dbaca4570cdac06c9ebdf2447b9955edeb1afdd763f24d98faec2103001a9e9"
|
|
8571
8581
|
},
|
|
8572
8582
|
{
|
|
8573
8583
|
"path": "tests/regrade_eligibility.mjs",
|
|
@@ -8619,6 +8629,11 @@
|
|
|
8619
8629
|
"mode": "0000644",
|
|
8620
8630
|
"sha256": "b56a6945713e738542d612f6d43dd9bd00ce1a370b3d7b3e130f4e59a056ee15"
|
|
8621
8631
|
},
|
|
8632
|
+
{
|
|
8633
|
+
"path": "tests/routine_schedule.mjs",
|
|
8634
|
+
"mode": "0000644",
|
|
8635
|
+
"sha256": "ba7f5690b9a3649a4c4cd2571d316aa837ffc01874d57aaa418dc72683c6a1f1"
|
|
8636
|
+
},
|
|
8622
8637
|
{
|
|
8623
8638
|
"path": "tests/routine_timers.mjs",
|
|
8624
8639
|
"mode": "0000644",
|
|
@@ -8632,7 +8647,7 @@
|
|
|
8632
8647
|
{
|
|
8633
8648
|
"path": "tests/run_routine.mjs",
|
|
8634
8649
|
"mode": "0000644",
|
|
8635
|
-
"sha256": "
|
|
8650
|
+
"sha256": "a33afdf5b7cbe592f77d5162ad8129f449efb0872ed2b484725c6d1bf0ba27c7"
|
|
8636
8651
|
},
|
|
8637
8652
|
{
|
|
8638
8653
|
"path": "tests/run_routine_two_root.mjs",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# 0188 — The weekly patrol runs on CI, and a declared cadence must have something that fires it
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-08-24
|
|
5
|
+
- **Tasks:** [#1003209](https://cloudbongos.com/builders#/task/1003209) (goal 1000079 — pay down the frozen debt and make enforcement durable; criterion C4 `reduction-durable`)
|
|
6
|
+
- **Builds on:** [ADR 0078](<redacted>.md) (deterministic-first routines + the `run-routine.js` dispatcher), [ADR 0180](<redacted>.md) §3 (the patrol's dependency checks), [ADR 0022](0022-secrets-policy.md) (the prevent → gate → patrol layering)
|
|
7
|
+
- **Related:** [ADR 0162](<redacted>.md) (patrol findings are advisory — verdicts never self-execute), [ADR 0093](<redacted>.md) §1 (where `routine-timers.js` landed — the in-process poller seam this deliberately does *not* use)
|
|
8
|
+
|
|
9
|
+
## 1. Context
|
|
10
|
+
|
|
11
|
+
Goal 1000076 shipped a weekly red-team patrol: `scripts/gds/redteam-patrol.js`
|
|
12
|
+
(full-git-history secret scan, dependency-drift review, workflow action-pin
|
|
13
|
+
check), the `.claude/scheduled-tasks/redteam-weekly` routine that describes how
|
|
14
|
+
to run it, a recipe, and a row in `control-manifest.js` asserting the control
|
|
15
|
+
still exists.
|
|
16
|
+
|
|
17
|
+
Nothing ever ran it. The follow-up audit
|
|
18
|
+
([`docs/audits/ai-dev-redteam-followup.md`](../audits/ai-dev-redteam-followup.md) §7)
|
|
19
|
+
put the failure in three parts:
|
|
20
|
+
|
|
21
|
+
1. **No committed scheduler** for `.claude/scheduled-tasks/` at all. ADR 0078
|
|
22
|
+
built the external dispatcher an operator was meant to wire into cron; the
|
|
23
|
+
wiring never happened, which `routine-timers.js`'s own header already admits
|
|
24
|
+
in so many words — *"nothing was ever durably scheduled."*
|
|
25
|
+
2. **No cadence to read.** The SKILL.md declared `mode:` and `steps:` — *how* to
|
|
26
|
+
run — but never *how often*. There was nothing for a scheduler to honour even
|
|
27
|
+
if one had looked.
|
|
28
|
+
3. **A manifest that was green anyway.** `control-manifest.js` proved the patrol
|
|
29
|
+
"wired" by finding the substring `redteam-patrol.js` inside the SKILL.md —
|
|
30
|
+
i.e. the routine mentioning its own scanner. The control read as connected
|
|
31
|
+
for months while it ran zero times.
|
|
32
|
+
|
|
33
|
+
This is the same shape the ADR 0022 audit found in the first place: a control
|
|
34
|
+
that can quietly stop existing (or, here, quietly never start) is not a control.
|
|
35
|
+
It is also the fifth instance in this goal of a more specific pattern — **an
|
|
36
|
+
enforcement surface that names a file goes VACUOUS, not red, when the thing it
|
|
37
|
+
names moves or stops.**
|
|
38
|
+
|
|
39
|
+
## 2. Decision
|
|
40
|
+
|
|
41
|
+
### 2.1 The runner is CI, not the always-on server
|
|
42
|
+
|
|
43
|
+
The obvious symmetry was to add the patrol beside `peer-vote-tally` in
|
|
44
|
+
`modules/autonomy/pollers/routine-timers.js` — the in-process timer built (as an
|
|
45
|
+
ADR 0078 amendment) for exactly the "deterministic routine nobody scheduled"
|
|
46
|
+
problem, whose header even reasons through why an in-process timer beat external
|
|
47
|
+
cron *there*.
|
|
48
|
+
|
|
49
|
+
**That reasoning does not transfer, and applying it here would have produced a
|
|
50
|
+
patrol that reports clean while scanning nothing.** All three checks read things
|
|
51
|
+
only the *repository* has:
|
|
52
|
+
|
|
53
|
+
| check | needs |
|
|
54
|
+
|---|---|
|
|
55
|
+
| history-secrets | the full git history |
|
|
56
|
+
| action-pins | `.github/workflows/` |
|
|
57
|
+
| dependency-review | a `package.json` + `package-lock.json` npm can audit |
|
|
58
|
+
|
|
59
|
+
A deployed instance runs a **vendored** core out of `node_modules/@bongos/core`.
|
|
60
|
+
That directory is not a git repo, carries no workflows, and ships no lockfile.
|
|
61
|
+
The timer would have found no history to scan, no workflows to check, and
|
|
62
|
+
nothing to audit — and every one of those degrades to *"nothing found"*. Worse
|
|
63
|
+
than unscheduled: confidently, weekly, wrong.
|
|
64
|
+
|
|
65
|
+
So the scheduler is `.github/workflows/redteam-patrol.yml` — a weekly GitHub
|
|
66
|
+
Actions lane with `fetch-depth: 0` and a pinned, checksum-verified gitleaks
|
|
67
|
+
binary (the missing dependency the audit listed as its own gap). It invokes
|
|
68
|
+
`run-routine.js redteam-weekly` rather than the patrol script directly, so the
|
|
69
|
+
SKILL.md's `steps:` remain the single source of truth for what a run does.
|
|
70
|
+
|
|
71
|
+
### 2.2 `cadence:` is a real frontmatter field
|
|
72
|
+
|
|
73
|
+
`parseRoutineFrontmatter` now reads `cadence: hourly|daily|weekly|monthly`. It
|
|
74
|
+
is **declarative only** — `run-routine.js` still runs a routine exactly once per
|
|
75
|
+
invocation. The cadence is the contract a committed scheduler must honour.
|
|
76
|
+
|
|
77
|
+
### 2.3 A cadence nothing fires is a red build
|
|
78
|
+
|
|
79
|
+
`scripts/gds/routine-schedule.js` (fitness Check 25) is the part that keeps
|
|
80
|
+
§7 from recurring. Its predicate is **relational, not existential**: it reads
|
|
81
|
+
the cadence each routine declares, reads the cron each scheduler carries,
|
|
82
|
+
classifies that cron into the period it actually realizes, and fails when the
|
|
83
|
+
two disagree in either direction —
|
|
84
|
+
|
|
85
|
+
- a routine declares a cadence and no scheduler fires it → red
|
|
86
|
+
- a scheduler dispatches a routine that no longer exists → red (rename rot)
|
|
87
|
+
- the cron's real period differs from the declared cadence → red
|
|
88
|
+
|
|
89
|
+
The third is the one that matters most, because **no existence or substring
|
|
90
|
+
predicate can ever see it.** Edit the cron from weekly to monthly and the file
|
|
91
|
+
still exists, the workflow still names the routine, and every "is it wired?"
|
|
92
|
+
check in the repo stays green.
|
|
93
|
+
|
|
94
|
+
The manifest row was re-pointed to match: "wired" for a scheduled control now
|
|
95
|
+
means the *scheduler* names the routine, not that the routine names itself.
|
|
96
|
+
|
|
97
|
+
### 2.4 Red means "a finding was lost", not "a finding exists"
|
|
98
|
+
|
|
99
|
+
A CI runner has no GDS session, so `--file` would have hit `cli-lib`'s
|
|
100
|
+
interactive 401 path — printing *"run /builder-reauth"* and calling
|
|
101
|
+
`process.exit(2)` into a cron log, indistinguishable from a crash, before the
|
|
102
|
+
findings were ever shown. `--file` now preflights the session and follows the
|
|
103
|
+
`main-audit.js --require-roster` doctrine: report loudly rather than
|
|
104
|
+
green-and-vacuous.
|
|
105
|
+
|
|
106
|
+
The exit code is a small table (`disposition()`), and the choice inside it is
|
|
107
|
+
deliberate. Findings are **advisory** (ADR 0162) — they are filed for
|
|
108
|
+
adjudication, not acted on — so a run that finds three things and files them is
|
|
109
|
+
a **success**:
|
|
110
|
+
|
|
111
|
+
| state | exit |
|
|
112
|
+
|---|---|
|
|
113
|
+
| ran, nothing to file | 0 |
|
|
114
|
+
| ran, dry run | 0 |
|
|
115
|
+
| ran, findings **filed** | 0 |
|
|
116
|
+
| ran, findings it **cannot** file | **2** |
|
|
117
|
+
|
|
118
|
+
Red is reserved for the single state where evidence evaporates. The alternative
|
|
119
|
+
— failing whenever findings exist — would have gone red on the very first run
|
|
120
|
+
and stayed red: the staging credential in commit `092e473` is still in history,
|
|
121
|
+
unrotated, so the history scan has a standing hit. A weekly job that is always
|
|
122
|
+
red is a weekly job nobody reads, which is the failure this whole goal is about.
|
|
123
|
+
|
|
124
|
+
Filing itself is optional and credential-gated: with no `GDS_PATROL_SESSION`
|
|
125
|
+
secret the patrol still runs every week and writes findings to the job summary.
|
|
126
|
+
That default matters because this same goal's C1 is about *removing* long-lived
|
|
127
|
+
tokens — the patrol must not require a new one to be useful.
|
|
128
|
+
|
|
129
|
+
## 3. Consequences
|
|
130
|
+
|
|
131
|
+
- The patrol runs unattended for the first time. The first scheduled runs are
|
|
132
|
+
expected to file a `history-secrets` report until commit `092e473` is scrubbed.
|
|
133
|
+
- Adding a routine to `.claude/scheduled-tasks/` now forces a choice: declare a
|
|
134
|
+
cadence and schedule it, or declare none and accept a warning that it is
|
|
135
|
+
manual-only. Silence is no longer an option.
|
|
136
|
+
- The check is tolerant of the two-repo split: `.claude/scheduled-tasks/` is
|
|
137
|
+
materialized into every instance, `.github/workflows/` is the core repo's and
|
|
138
|
+
does not travel — so in a checkout with no workflows directory, unscheduled
|
|
139
|
+
routines are warnings, not violations.
|
|
140
|
+
- `cronCadence()` refuses step values, lists and ranges rather than guessing.
|
|
141
|
+
An exotic schedule cannot be described by a cadence word and must be reworked
|
|
142
|
+
or left manual — the safe direction, since a clever cron silently counting as
|
|
143
|
+
`weekly` would reopen the drift this exists to catch.
|
|
144
|
+
|
|
145
|
+
## 4. Alternatives rejected
|
|
146
|
+
|
|
147
|
+
- **An in-process timer in `routine-timers.js`.** §2.1 — the server cannot see
|
|
148
|
+
the evidence. Rejected on correctness, not cost.
|
|
149
|
+
- **Teaching `routine-timers.js` to discover `.claude/scheduled-tasks/` and
|
|
150
|
+
honour `cadence:` generically.** The task offered this first and it is the more
|
|
151
|
+
general mechanism, but its only candidate user cannot run there, so it would
|
|
152
|
+
have been a scheduler built for nobody. The `cadence:` field is deliberately
|
|
153
|
+
*scheduler-agnostic*, so an instance that later wants an in-process lane for a
|
|
154
|
+
server-side routine can read the same declaration.
|
|
155
|
+
- **Failing the job whenever findings exist.** Permanent red, then ignored. §2.4.
|
|
156
|
+
- **Requiring a GDS session secret.** Would have made the whole lane inert until
|
|
157
|
+
a credential was provisioned, and adds a long-lived token to a goal whose C1 is
|
|
158
|
+
about eliminating one.
|
|
159
|
+
- **A deeper `control-manifest.js` predicate instead of a new check.** The
|
|
160
|
+
manifest's shallowness is deliberate and documented (existence + one wiring
|
|
161
|
+
substring); the cadence↔cron agreement is relational and does not fit that
|
|
162
|
+
shape. Re-pointing the row *and* adding the check keeps each honest.
|
package/docs/adr/README.md
CHANGED
|
@@ -261,5 +261,6 @@ This keeps the decision history honest and traceable.
|
|
|
261
261
|
| 0185 | [**The spark-handoff credit split: 70% developer, 30% spark author** ([task 1002923](https://cloudbongos.com/builders#/task/1002923), BV1.R147, goal 1000062 — completing the chain [task 1002920](https://cloudbongos.com/builders#/task/1002920) mark → [task 1002921](https://cloudbongos.com/builders#/task/1002921) queue → [task 1002922](https://cloudbongos.com/builders#/task/1002922) pickup). [ADR 0172](<redacted>.md) built the ideator streams and paid `captured_by`, the only person an idea then had. The spark handoff created a second: R146 develops a spark IN PLACE, preserving the author while recording a `developed_by`. **R146 therefore shipped paying NOTHING on purpose** — not an oversight but a refusal to guess, because `awardIdeaFullPassCredit`'s idempotency predicate is `(builder_id, reason)`, so paying the DEVELOPER would not have blocked a later payment to the AUTHOR and one idea would have paid twice; the cost of that caution was that a developed Full Idea received stream 2 **never** unless its author resubmitted. Owner decision (2026-08-22): **70/30 favouring the developer**, on BOTH existing streams, one ratio so there is one rule to explain. The reasoning is behavioural, not sentimental — developing someone else's spark is the scarce act the queue depends on, and at an even split it pays HALF what authoring your own Full Idea pays for the same writing work, so the rational move is always to write your own and the R145 queue stays a list nobody reads. The author keeps 30% plus their share of stream 1 on every task shipped under the idea, forever. `IDEA_DEVELOPER_SHARE = 0.70` is a **policy constant in `credits.js`** beside the pass bar and quick factor (ADR 0172's own consequence, the ADR 0023 precedent): a share of someone's earnings deserves a review and a commit, not a runtime edit. **The structural half is what retires R146's caution:** the economy module now derives the payees itself (`ideaCreditParties` reads `captured_by` + `developed_by`), so the caller can no longer choose a recipient — the develop route hands over an idea and a score, never a payee, and books the split like any other path (fail-open; the development has already committed). Three arithmetic properties, each a real bug if missed: the total is **conserved** (developer rounded, author gets the REMAINDER — rounding both pays 6 out of a 5-credit payout); a **self-developed** idea collapses to ONE payee, because two rows for one builder would silently UNDER-pay under the per-builder idempotency predicate, not double-pay (the comparison normalises via `String()` since `bigint` arrives as a string, so a type mismatch cannot become a phantom payee); and **zero-value rows are dropped**. Ids otherwise pass through unconverted — `Number()`-ing a `bigint` risks precision. `builder_id` in the idempotency predicate, which entered as an index optimisation (the R143/IC2 grader finding), is now **load-bearing for correctness**: two payees share one `reason`, so a reason-only predicate would refuse the second, and a partially-booked split completes on the next call. **Not decided here:** stream 3's split (ships with the Board Room, task 1002999), and whether `origin='develop'` in `ideas_task_lineage` belongs to whoever promotes a DEVELOPED idea into a task — ADR 0172 §5 says the row is "set at promotion and at develop (R146)", but R146 creates no task, so that origin stays deliberately unwired rather than invented; it changes who stream 1 pays.](<redacted>.md) | economy / ideator lane |
|
|
262
262
|
| 0186 | [**Per-project idea templates: the seam is already built; the economics are the open problem** ([task 1002932](https://cloudbongos.com/builders#/task/1002932), BV1.R156, goal 1000062 — a SPIKE, output is this ADR plus a task list, no implementation). The literal question — how does a founder define their own idea shape without a schema change or forking the scorer — is **already answered by shipped work**: R141 gave every template function an optional `registry` defaulting to the built-in one, so validation and scoring both read the declaration as data; `ideas_grades.template_key` is per-row with a format CHECK whose own comment says a project key "stays addressable"; and `provisioning_instances.settings jsonb` is an existing per-project home. **No schema change, no fork.** Decision A: a **`project-settings` kernel port**, sibling of `builder-settings`, read via `resolveOptional` so a vanilla instance with no provisioning module degrades to the built-in registry; `base-v1` stays the default and the fallback for an unresolvable key. Rejected a new `ideas_templates` table (second home for data that has one) and a config file (per-instance, but one instance hosts many projects). **Decision B is the finding that matters.** Completeness is normalised 0–100 WITHIN its template (mean of per-field ratios), while karma reads it against an absolute bar (`IDEA_FULL_PASS_BAR = 60`, `IDEA_FULL_PASS_CREDIT = 5`) — so template choice becomes a credit-minting lever: `base-v1` needs ~200 characters for 5 credits, a 1-field template with `substanceMin` 10 needs ~10, a ~20× arbitrage handed to exactly the projects this ADR empowers. Shipping A without B would make the feature a way to print credits. The fix separates the two questions one number is answering: **completeness stays relative** (it is the right signal for triage ordering and the hall meter) and **karma stops reading it**, switching to an absolute substance total the scorer already computes per field and currently discards after averaging. An interim guard (≥3 required fields, ≥120 total `substanceMin`) bounds the arbitrage without fixing it. Decision C: an optional per-field `weight` (default 1) turning the unweighted mean into a weighted one — today's behaviour is the all-weights-1 case, so it is a data addition and one arithmetic line. Decision D: the five base fields are **not** mandatory (forcing them would make "define your own" cosmetic), replaced by two constraints — a template must declare ≥1 required field, and a stored template is validated at WRITE time so a malformed one is refused there rather than discovered inside the capture path, which must never throw. **Boundary note:** goal 1000062's wall is `[ideas, hall-ui]`, so the port task (provisioning + kernel) and the karma task (economy) cannot be claimed under it.](<redacted>.md) | ideas / templates |
|
|
263
263
|
| 0187 | [**The OAuth web-handshake cookies live 30 minutes, and move together** ([task 1003234](https://cloudbongos.com/builders#/task/1003234), goal 1000058). The three cookies `/auth/web/start` sets — the CSRF state, the validated return target, the repo-scope opt-in — were 10 minutes each, a hall builder's timescale; a founder's first sign-in can include a GitHub SIGNUP with email verification and came back past ten minutes to "State mismatch" with the return + repo scope gone, and the page's retry door was a bare start. Decision: one `HANDSHAKE_COOKIE_MS = 30 min` for all three (they MUST move together — a state outliving its return cookie lands on the default landing with no scope, a return outliving its state lands on State mismatch), and every OAuth error page plus the access-pending auto-walk-in rebuilds its door from whatever cookies are still valid (`recoveryStartHref`, through the same open-redirect allowlist). What did NOT change: the state is still single-use, still `httpOnly`/`sameSite=lax`/apex-scoped, the code still the bearer — only the window for a legitimate handshake grew. Rejected: lengthening only return + repos (keeps the dead end and breaks the one-lifetime invariant the other way), a server-side state store, sixty minutes. The Discord-link state cookie is a different flow and keeps 10.](<redacted>.md) | auth / security posture |
|
|
264
|
+
| 0188 | [**The weekly patrol runs on CI, and a declared cadence must have something that fires it** ([task 1003209](https://cloudbongos.com/builders#/task/1003209), goal 1000079). Goal 1000076 shipped a weekly red-team patrol, a routine describing how to run it, a recipe, and a manifest row — and nothing ever ran it; the follow-up audit §7 named all three halves (no committed scheduler, no cadence for a scheduler to read, and a manifest green anyway because its wiring predicate was satisfied by the SKILL.md mentioning its own scanner). The symmetric fix — an in-process timer beside `peer-vote-tally` in `routine-timers.js` — is **wrong on correctness, not cost**: all three patrol checks read things only the REPO has (full git history, `.github/workflows/`, an auditable lockfile), and a deployed instance runs a VENDORED core out of `node_modules/@bongos/core` that has none of them, so the timer would have scanned nothing and reported clean — worse than unscheduled. The runner is therefore CI: a weekly Actions lane with `fetch-depth: 0` and a pinned, checksum-verified gitleaks (the missing binary the audit listed separately), invoking `run-routine.js redteam-weekly` so the SKILL.md's steps stay the single source of truth. `cadence:` becomes a real frontmatter field — declarative only, scheduler-agnostic. The load-bearing piece is `routine-schedule.js` (fitness Check 25), whose predicate is **relational, not existential**: it classifies the cron into the period it actually realizes and fails when that disagrees with the declared cadence — the one rot mode no substring or existence check can EVER see, since editing weekly→monthly leaves every "is it wired?" predicate green. The manifest row was re-pointed to match ("wired" now means the SCHEDULER names the routine). Finally the exit code says **a finding was LOST, not that a finding exists**: findings are advisory (ADR 0162), so filing three is a SUCCESS and red is reserved for findings that cannot be recorded — failing on findings would have gone red on run one and stayed there (the unrotated staging credential in `092e473` is a standing history hit), and an always-red weekly job is one nobody reads. Filing is credential-gated but OPTIONAL — with no session secret the patrol still runs and writes its findings to the job summary, which matters because this goal's C1 is about eliminating long-lived tokens, not adding one. Rejected: the in-process timer; teaching `routine-timers.js` generic cadence discovery (a scheduler built for nobody — its only candidate user cannot run there); failing whenever findings exist; requiring the secret; a deeper manifest predicate instead of a new check.](<redacted>.md) | security / enforcement durability |
|
|
264
265
|
|
|
265
266
|
> ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
|
package/docs/file-map.md
CHANGED
|
@@ -389,7 +389,7 @@ tests/
|
|
|
389
389
|
<!-- BEGIN GENERATED FILE-MAP .claude/scheduled-tasks/ (scripts/gds/gen-file-map.js — do not hand-edit; notes live in docs/file-map.notes.json) -->
|
|
390
390
|
```
|
|
391
391
|
.claude/scheduled-tasks/
|
|
392
|
-
└── redteam-weekly/SKILL.md ← weekly deterministic red-team patrol (task 1003128): full-history gitleaks scan + dependency drift review (audit delta, cooldown, young pkgs, stale overrides) + action-pin check → files security reports (advisory, ADR 0162)
|
|
392
|
+
└── redteam-weekly/SKILL.md ← weekly deterministic red-team patrol (task 1003128): full-history gitleaks scan + dependency drift review (audit delta, cooldown, young pkgs, stale overrides) + action-pin check → files security reports (advisory, ADR 0162). FIRED BY .github/workflows/redteam-patrol.yml, Mondays 06:17 UTC — `cadence: weekly` here and that cron must agree or fitness Check 25 (scripts/gds/routine-schedule.js) fails the gate (task 1003209, ADR 0188)
|
|
393
393
|
```
|
|
394
394
|
<!-- END GENERATED FILE-MAP .claude/scheduled-tasks/ -->
|
|
395
395
|
|
|
@@ -16,18 +16,69 @@ node scripts/gds/redteam-patrol.js --canary # self-test detection only
|
|
|
16
16
|
node scripts/gds/redteam-patrol.js --canary --file # prove the full pipe (files + dispute-closes)
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
## What actually fires it
|
|
20
|
+
|
|
21
|
+
**[`.github/workflows/redteam-patrol.yml`](../../.github/workflows/redteam-patrol.yml)
|
|
22
|
+
— Mondays 06:17 UTC** (task 1003209, ADR 0188). Before that lane the patrol was,
|
|
23
|
+
in the follow-up audit's words, a manual script: tracked, documented, wired into
|
|
24
|
+
the control manifest, and run zero times.
|
|
25
|
+
|
|
26
|
+
The routine itself lives at
|
|
27
|
+
[`.claude/scheduled-tasks/redteam-weekly/`](../../.claude/scheduled-tasks/redteam-weekly/SKILL.md)
|
|
28
|
+
(`mode: deterministic`, `cadence: weekly`, one step, sink `gds`) and the workflow
|
|
29
|
+
invokes it through `run-routine.js`, so the SKILL.md's `steps:` stay the single
|
|
30
|
+
source of truth for what a run does. Weekly is the cadence because the checks
|
|
31
|
+
watch drift measured in days (release-age, new advisories), not minutes.
|
|
32
|
+
|
|
33
|
+
**The cadence and the cron are checked against each other.** `cadence: weekly` in
|
|
34
|
+
the SKILL.md and `cron: '17 6 * * 1'` in the workflow must agree, or
|
|
35
|
+
`scripts/gds/routine-schedule.js` (fitness Check 25) fails the merge gate — as it
|
|
36
|
+
does if the workflow stops firing the routine, or fires one that no longer
|
|
37
|
+
exists. Change one and change the other in the same commit. The odd minute is
|
|
38
|
+
deliberate: GitHub queues scheduled workflows and top-of-hour is its worst
|
|
39
|
+
congestion window.
|
|
40
|
+
|
|
41
|
+
**Why CI and not the always-on server.** All three checks read things only the
|
|
42
|
+
*repo* has — full git history, `.github/workflows/`, an auditable lockfile. A
|
|
43
|
+
deployed instance runs a vendored core out of `node_modules/@bongos/core`, which
|
|
44
|
+
has none of them, so an in-process timer would have scanned nothing and reported
|
|
45
|
+
clean. ADR 0188 §2.1.
|
|
46
|
+
|
|
47
|
+
### Filing from CI (optional)
|
|
48
|
+
|
|
49
|
+
A CI runner has no GDS session, so by default the scheduled run **reports but
|
|
50
|
+
cannot file**: findings go to the Actions job summary and the job exits 2. To let
|
|
51
|
+
it file, set the repo secret **`GDS_PATROL_SESSION`** to a GDS CLI session JSON
|
|
52
|
+
(`{"token":…,"api_base":…}`) for a builder holding `security.report.file`; the
|
|
53
|
+
workflow writes it to the session path `instance-config` resolves and the patrol
|
|
54
|
+
files normally. This is opt-in on purpose — the patrol must not *require* a
|
|
55
|
+
long-lived token to be useful.
|
|
56
|
+
|
|
57
|
+
**Red means a finding was LOST, not that a finding exists.** Findings are
|
|
58
|
+
advisory (ADR 0162), so a run that files three of them is a success:
|
|
59
|
+
|
|
60
|
+
| the run… | exit |
|
|
61
|
+
|---|---|
|
|
62
|
+
| found nothing | 0 |
|
|
63
|
+
| was a dry run | 0 |
|
|
64
|
+
| filed its findings | 0 |
|
|
65
|
+
| has findings it cannot file | **2** |
|
|
66
|
+
|
|
67
|
+
Expect a standing `history-secrets` hit until the staging credential in commit
|
|
68
|
+
`092e473` is scrubbed from history. That is the patrol working, not the lane
|
|
69
|
+
being broken.
|
|
24
70
|
|
|
25
71
|
## Prerequisites
|
|
26
72
|
|
|
27
73
|
- **gitleaks** on PATH (`brew install gitleaks`) or `GITLEAKS_BIN=<path>`. The
|
|
28
74
|
history scan degrades LOUDLY (a printed warning, no silent skip) when absent;
|
|
29
|
-
the dependency + pin checks still run.
|
|
75
|
+
the dependency + pin checks still run. The scheduled lane installs it itself —
|
|
76
|
+
version-pinned and checksum-verified, the same binary and digest the unit gate
|
|
77
|
+
uses, rather than a mutable action tag (the tj-actions lesson).
|
|
30
78
|
- A GDS session (the normal builder token) for `--file`; dry runs need nothing.
|
|
79
|
+
`--file` preflights it, so a sessionless run prints its findings and says it
|
|
80
|
+
could not file them — it never drops into the CLI's interactive re-auth wall,
|
|
81
|
+
which in a cron log is indistinguishable from a crash.
|
|
31
82
|
- Network access to the npm registry for the dependency checks — registry
|
|
32
83
|
failures print notes and skip that item (advisory layer: fail-open, loud).
|
|
33
84
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.341",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.341",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.341",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -69,10 +69,17 @@ const CONTROLS = [
|
|
|
69
69
|
wiredIn: { file: '.github/workflows/unit.yml', needle: '--knip' },
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
|
-
|
|
72
|
+
// Task 1003209 re-pointed this row. It used to prove the routine mentioned
|
|
73
|
+
// its own scanner — a wiring substring satisfied by the SKILL.md talking
|
|
74
|
+
// about itself, which is why the manifest read GREEN for months while the
|
|
75
|
+
// patrol never ran once (follow-up audit §7). "Wired" for a scheduled
|
|
76
|
+
// control has to mean SOMETHING FIRES IT, so the needle now lives in the
|
|
77
|
+
// scheduler. The period itself is checked relationally, where a substring
|
|
78
|
+
// can't reach: scripts/gds/routine-schedule.js (fitness Check 25).
|
|
79
|
+
label: 'patrol: weekly red-team routine is actually scheduled',
|
|
73
80
|
file: '.claude/scheduled-tasks/redteam-weekly/SKILL.md',
|
|
74
|
-
wiredIn: { file: '.
|
|
75
|
-
alsoExists: ['scripts/gds/redteam-patrol.js'],
|
|
81
|
+
wiredIn: { file: '.github/workflows/redteam-patrol.yml', needle: 'run-routine.js redteam-weekly' },
|
|
82
|
+
alsoExists: ['scripts/gds/redteam-patrol.js', 'scripts/gds/routine-schedule.js', 'tests/routine_schedule.mjs'],
|
|
76
83
|
},
|
|
77
84
|
|
|
78
85
|
// ── goal 1000077's four guards (task 1003169 / G05) ──────────────────────
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1305,6 +1305,7 @@ const CHECKS = [
|
|
|
1305
1305
|
// and the check gets its own line back.
|
|
1306
1306
|
require('./version-literal-guard.js').checkNoVersionLiterals,
|
|
1307
1307
|
require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
|
|
1308
|
+
require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
|
|
1308
1309
|
];
|
|
1309
1310
|
|
|
1310
1311
|
function runAll() {
|
|
@@ -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,10 @@ 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)
|
|
1113
1115
|
// ---------------------------------------------------------------------------
|
|
1114
|
-
const CORE_VERSION = '1.19.
|
|
1116
|
+
const CORE_VERSION = '1.19.341'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1115
1117
|
|
|
1116
1118
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1117
1119
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
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
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// tests/routine_schedule.mjs — a declared cadence must have something that fires
|
|
2
|
+
// it (task 1003209, goal 1000079; ADR 0188, follow-up audit §7).
|
|
3
|
+
//
|
|
4
|
+
// The check exists because goal 1000076's patrol was "wired" by every predicate
|
|
5
|
+
// the repo had and still ran zero times. So these tests are written against the
|
|
6
|
+
// ways that state can come BACK, not merely against the happy path: the strongest
|
|
7
|
+
// one is the cron silently drifting off the declared cadence, which no
|
|
8
|
+
// existence/substring predicate can ever catch.
|
|
9
|
+
//
|
|
10
|
+
// Hermetic — the evaluator is pure and driven with fixtures; the two live-tree
|
|
11
|
+
// tests at the bottom assert the real repo is in the state this task shipped.
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const rs = require(path.join(ROOT, 'scripts', 'gds', 'routine-schedule.js'));
|
|
22
|
+
const { parseRoutineFrontmatter } = require(path.join(ROOT, 'scripts', 'gds', 'run-routine.js'));
|
|
23
|
+
|
|
24
|
+
const WEEKLY_WF = {
|
|
25
|
+
file: 'redteam-patrol.yml',
|
|
26
|
+
text: [
|
|
27
|
+
'on:',
|
|
28
|
+
' schedule:',
|
|
29
|
+
" - cron: '17 6 * * 1'",
|
|
30
|
+
' workflow_dispatch:',
|
|
31
|
+
'jobs:',
|
|
32
|
+
' patrol:',
|
|
33
|
+
' steps:',
|
|
34
|
+
' - run: node scripts/gds/run-routine.js redteam-weekly',
|
|
35
|
+
].join('\n'),
|
|
36
|
+
};
|
|
37
|
+
const routines = (withCadence, withoutCadence = []) => ({ withCadence, withoutCadence });
|
|
38
|
+
const WEEKLY = [{ name: 'redteam-weekly', cadence: 'weekly', steps: [] }];
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------- cron parsing
|
|
41
|
+
|
|
42
|
+
test('cronCadence classifies the four periods a routine may declare', () => {
|
|
43
|
+
assert.equal(rs.cronCadence('17 6 * * 1'), 'weekly');
|
|
44
|
+
assert.equal(rs.cronCadence('17 6 * * *'), 'daily');
|
|
45
|
+
assert.equal(rs.cronCadence('17 6 3 * *'), 'monthly');
|
|
46
|
+
assert.equal(rs.cronCadence('17 * * * *'), 'hourly');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('cronCadence refuses what its vocabulary cannot describe, rather than guessing', () => {
|
|
50
|
+
// Unclassifiable must mean "never matches a declared cadence" — the safe
|
|
51
|
+
// direction. A clever cron that silently counted as `weekly` would reopen
|
|
52
|
+
// exactly the drift this check exists to catch.
|
|
53
|
+
for (const expr of ['*/5 * * * *', '0 6 * * 1-5', '0 6 * * 1,4', '0 0 1 1 *', '17 6 * *', '', 'nonsense']) {
|
|
54
|
+
assert.equal(rs.cronCadence(expr), null, `${expr} should not classify`);
|
|
55
|
+
}
|
|
56
|
+
// A day-of-month AND day-of-week together is an OR in cron — neither period.
|
|
57
|
+
assert.equal(rs.cronCadence('17 6 3 * 1'), null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('workflowCrons reads every cron a workflow declares, quoted or bare, ignoring trailing comments', () => {
|
|
61
|
+
const y = [" schedule:", " - cron: '17 6 * * 1'", ' - cron: "0 3 * * *"', ' - cron: 5 * * * * # bare + comment'].join('\n');
|
|
62
|
+
assert.deepEqual(rs.workflowCrons(y), ['17 6 * * 1', '0 3 * * *', '5 * * * *']);
|
|
63
|
+
assert.deepEqual(rs.workflowCrons(''), []);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ------------------------------------------------------------- what counts as
|
|
67
|
+
// an invocation
|
|
68
|
+
|
|
69
|
+
test('invokesRoutine accepts the dispatcher form and a verbatim step command', () => {
|
|
70
|
+
assert.equal(rs.invokesRoutine('run: node scripts/gds/run-routine.js redteam-weekly', 'redteam-weekly'), true);
|
|
71
|
+
assert.equal(rs.invokesRoutine('run: node scripts/gds/run-routine.js --dry-run redteam-weekly', 'redteam-weekly'), true);
|
|
72
|
+
assert.equal(
|
|
73
|
+
rs.invokesRoutine('run: node scripts/gds/redteam-patrol.js --file', 'redteam-weekly',
|
|
74
|
+
[{ command: 'node scripts/gds/redteam-patrol.js --file' }]),
|
|
75
|
+
true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('invokesRoutine does not accept a prefix match for a different routine', () => {
|
|
79
|
+
// `redteam-weekly-v2` must not satisfy `redteam-weekly` — that would let a
|
|
80
|
+
// renamed routine keep the old one's green.
|
|
81
|
+
assert.equal(rs.invokesRoutine('node scripts/gds/run-routine.js redteam-weekly-v2', 'redteam-weekly'), false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ------------------------------------------------------------------ the check
|
|
85
|
+
|
|
86
|
+
test('a routine fired weekly by a weekly cron passes', () => {
|
|
87
|
+
const { violations } = rs.evaluate(routines(WEEKLY), [WEEKLY_WF]);
|
|
88
|
+
assert.deepEqual(violations, []);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('THE regression this check exists for: the cron drifts off the declared cadence', () => {
|
|
92
|
+
// Every existence/substring predicate in the repo stays green here — the file
|
|
93
|
+
// exists, the workflow still names the routine, the manifest needle still hits.
|
|
94
|
+
// Only a relational check sees it.
|
|
95
|
+
const drifted = { ...WEEKLY_WF, text: WEEKLY_WF.text.replace('17 6 * * 1', '17 6 3 * *') };
|
|
96
|
+
const { violations } = rs.evaluate(routines(WEEKLY), [drifted]);
|
|
97
|
+
assert.equal(violations.length, 1);
|
|
98
|
+
assert.match(violations[0], /declares cadence: weekly but no scheduler fires it at that period/);
|
|
99
|
+
assert.match(violations[0], /→ monthly/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('dispatch-only is not scheduled — the exact state the audit called "decorative"', () => {
|
|
103
|
+
const manual = { ...WEEKLY_WF, text: WEEKLY_WF.text.replace(/ schedule:\n.*\n/, '') };
|
|
104
|
+
const { violations } = rs.evaluate(routines(WEEKLY), [manual]);
|
|
105
|
+
assert.equal(violations.length, 1);
|
|
106
|
+
assert.match(violations[0], /carries no 'on: schedule:' cron \(manual dispatch only\)/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a cadence with no scheduler at all is a violation where CI exists', () => {
|
|
110
|
+
const { violations } = rs.evaluate(routines(WEEKLY), []);
|
|
111
|
+
assert.equal(violations.length, 1);
|
|
112
|
+
assert.match(violations[0], /NOTHING fires it/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('rename rot is caught from the other side: a scheduler dispatching a routine that is gone', () => {
|
|
116
|
+
const { violations } = rs.evaluate(routines([], [{ name: 'something-else' }]), [WEEKLY_WF]);
|
|
117
|
+
assert.equal(violations.length, 1, violations.join(' | '));
|
|
118
|
+
assert.match(violations[0], /dispatches routine 'redteam-weekly', which has no/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a workflow naming its own invocation twice reports one problem, not two', () => {
|
|
122
|
+
const twice = { ...WEEKLY_WF, text: `# see run-routine.js redteam-weekly below\n${WEEKLY_WF.text}` };
|
|
123
|
+
const { violations } = rs.evaluate(routines([], []), [twice]);
|
|
124
|
+
assert.equal(violations.length, 1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('an unknown cadence word is refused rather than silently unenforceable', () => {
|
|
128
|
+
const { violations } = rs.evaluate(routines([{ name: 'r', cadence: 'fortnightly', steps: [] }]), []);
|
|
129
|
+
assert.equal(violations.length, 1);
|
|
130
|
+
assert.match(violations[0], /unknown cadence "fortnightly"/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('an instance checkout (no .github/workflows) warns instead of red-building', () => {
|
|
134
|
+
// Routines are materialized into every instance; .github/workflows is the core
|
|
135
|
+
// repo's and does not travel. Hard-failing there would red every instance for
|
|
136
|
+
// owning a scheduler the core cannot see.
|
|
137
|
+
const { violations, warnings } = rs.evaluate(routines(WEEKLY), null);
|
|
138
|
+
assert.deepEqual(violations, []);
|
|
139
|
+
assert.equal(warnings.length, 1);
|
|
140
|
+
assert.match(warnings[0], /NOTHING fires it/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a routine with no cadence is a warning — it can only ever be run by hand', () => {
|
|
144
|
+
const { violations, warnings } = rs.evaluate(routines([], [{ name: 'manual-thing' }]), []);
|
|
145
|
+
assert.deepEqual(violations, []);
|
|
146
|
+
assert.match(warnings.join(' '), /declares no cadence/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// --------------------------------------------------------------- the live tree
|
|
150
|
+
|
|
151
|
+
test('the frontmatter parser reads cadence: alongside mode: and steps:', () => {
|
|
152
|
+
const md = ['---', 'mode: deterministic', 'cadence: weekly', 'description: d', 'steps:',
|
|
153
|
+
' - command: node x.js', ' sink: gds', '---', '# body'].join('\n');
|
|
154
|
+
const fm = parseRoutineFrontmatter(md);
|
|
155
|
+
assert.equal(fm.cadence, 'weekly');
|
|
156
|
+
assert.equal(fm.mode, 'deterministic');
|
|
157
|
+
assert.equal(fm.steps.length, 1);
|
|
158
|
+
// Absent cadence must be null, not undefined — evaluate() branches on truthiness.
|
|
159
|
+
assert.equal(parseRoutineFrontmatter('---\nmode: deterministic\n---\n').cadence, null);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('LIVE: redteam-weekly declares a cadence and this repo actually fires it', () => {
|
|
163
|
+
const r = rs.checkRoutineCadencesScheduled();
|
|
164
|
+
assert.equal(r.hardFail, false, r.violations.join(' | '));
|
|
165
|
+
assert.ok(rs.readRoutines().withCadence.some((x) => x.name === 'redteam-weekly'),
|
|
166
|
+
'redteam-weekly must declare a cadence — without it nothing can schedule it');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('LIVE: the patrol workflow installs gitleaks, which the history scan cannot run without', () => {
|
|
170
|
+
// The follow-up audit listed the missing binary as its own gap; a scheduled
|
|
171
|
+
// patrol without it degrades to "history scan SKIPPED" every week forever.
|
|
172
|
+
const wf = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'redteam-patrol.yml'), 'utf8');
|
|
173
|
+
assert.match(wf, /GITLEAKS_BIN=/, 'the patrol must be handed a gitleaks binary');
|
|
174
|
+
assert.match(wf, /sha256sum -c -/, 'the binary must be checksum-verified (the tj-actions lesson)');
|
|
175
|
+
assert.match(wf, /fetch-depth: 0/, 'a shallow clone makes the history scan a confident no-op');
|
|
176
|
+
});
|
package/tests/run_routine.mjs
CHANGED
|
@@ -49,8 +49,11 @@ test('a top-level key after steps ends the list', () => {
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
test('no frontmatter → empty contract', () => {
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// `cadence: null` joined the contract at task 1003209 — routine-schedule.js
|
|
53
|
+
// branches on its truthiness, so "absent" must be null, never undefined.
|
|
54
|
+
const empty = { mode: null, cadence: null, steps: [], description: null };
|
|
55
|
+
assert.deepEqual(parseRoutineFrontmatter('just a body'), empty);
|
|
56
|
+
assert.deepEqual(parseRoutineFrontmatter(''), empty);
|
|
54
57
|
});
|
|
55
58
|
|
|
56
59
|
test('mode-only frontmatter (judgment) → no steps', () => {
|