create-pmos 0.27.0 → 0.29.0
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/README.md +26 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/scripts/pmos-log.sh +29 -2
- package/payload/scripts/workflow-state.js +40 -6
- package/payload/skills/evaluator.skill +45 -12
- package/payload/skills/pmos-router.skill +1 -1
- package/payload/skills-native/evaluator/SKILL.md +45 -12
- package/payload/skills-native/pmos-router/SKILL.md +1 -1
- package/workspace/README.md +22 -0
- package/workspace/lib/drive-guard.js +421 -30
- package/workspace/lib/engine/workflow-state.js +40 -6
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/data.js +17 -0
- package/workspace/public/pm/panel-initiatives.js +161 -3
- package/workspace/server.js +246 -6
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/* drive-guard — the driver's ONLY write path (pmos-autonomous-drive-build, R9
|
|
2
|
+
/* drive-guard — the driver's ONLY write path (pmos-autonomous-drive-build, R9;
|
|
3
|
+
* hardened from a denylist to an allowlist by `guard-and-ledger-hardening`, 2026-08-04).
|
|
3
4
|
*
|
|
4
5
|
* THE PROBLEM THIS EXISTS FOR, stated plainly because it is easy to under-rate:
|
|
5
6
|
* nothing in PMOS checks an identity. `/api/gate` sets `pm: true` unconditionally
|
|
@@ -20,10 +21,44 @@
|
|
|
20
21
|
* that throws, and the tests ATTEMPT each forbidden write and assert the throw. A rule the driver
|
|
21
22
|
* is trusted to follow is exactly what leg A showed cannot be trusted.
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
24
|
+
* ── WHY THIS IS NOW AN ALLOWLIST ────────────────────────────────────────────────────────────────
|
|
25
|
+
* The first version was a denylist, and its suite DID attempt each forbidden write and assert the
|
|
26
|
+
* throw — six attempts, six assertions, exactly as the paragraph above claims. It still shipped
|
|
27
|
+
* SEVEN bypasses, each measured by an independent pass
|
|
28
|
+
* (planning/explainers/work-item-entity-surface-explainer.md) by running it against this module:
|
|
29
|
+
*
|
|
30
|
+
* 1. `PM_approved` nested 11 deep — recursion capped at `depth > 8`
|
|
31
|
+
* 2. `PM_approved: "true"` or `: 1` — strict `v === true`
|
|
32
|
+
* 3. the payload JSON.stringify'd — the `"` defeated /PM_approved\s*[:=]\s*true/
|
|
33
|
+
* 4. `status: 'PROMOTE'` / `'KILL'` — the regex required -D / -ED
|
|
34
|
+
* 5. `payload.frontmatter.status` — the structured status check was top-level only
|
|
35
|
+
* 6. `assertWritable({})` — FAILED OPEN: no file, no payload, no text → returned true
|
|
36
|
+
* 7. `parent_kr`, an evals verdict,
|
|
37
|
+
* `DECISIONS.md` — never named at all
|
|
38
|
+
*
|
|
39
|
+
* Plus a FALSE POSITIVE the other way: a legitimate initiative record was refused because its
|
|
40
|
+
* `status:` prose contained the word "promoted".
|
|
41
|
+
*
|
|
42
|
+
* The suite probed THE LIST. Nothing probed THE SHAPE OF THE LIST — and a denylist's shape is
|
|
43
|
+
* "everything somebody thought of". So:
|
|
44
|
+
*
|
|
45
|
+
* - sinks are ALLOWLISTED, closing (7) as a class rather than as three strings;
|
|
46
|
+
* - the content guard reads the SERIALIZED request, closing (1), (2) and (3) together — depth,
|
|
47
|
+
* type-looseness and stringification all vanish when you look at the text the write would
|
|
48
|
+
* actually produce;
|
|
49
|
+
* - an unreadable request THROWS, closing (6), the worst of the seven;
|
|
50
|
+
* - the disposition match is widened (4), recurses (5), and is anchored to the status value's
|
|
51
|
+
* LEADING TOKEN, so a real disposition is refused and prose that merely says "promoted" is not.
|
|
52
|
+
*
|
|
53
|
+
* SCOPE, honestly, and unchanged: this binds the DRIVER, not the repo. A human, another agent, or a
|
|
54
|
+
* careless `fs.writeFileSync` elsewhere is unaffected. It is a seatbelt on one vehicle, not a law of
|
|
25
55
|
* physics — and that limit is the argument for making O5's boolean falsifiable at the engine, which
|
|
26
56
|
* is a separate initiative this one does not pretend to have done.
|
|
57
|
+
*
|
|
58
|
+
* AND THE LIMIT WORTH SAYING OUT LOUD: **nothing imports this module** — only workspace/test.js.
|
|
59
|
+
* The live acceptance writer at `POST /api/gate` appends directly and never calls it. This hardening
|
|
60
|
+
* makes the boundary correct; it does not make it load-bearing. Its whole value is that when
|
|
61
|
+
* something does adopt it, it will not adopt seven bypasses with it.
|
|
27
62
|
*/
|
|
28
63
|
|
|
29
64
|
const path = require('path');
|
|
@@ -36,53 +71,406 @@ class ForbiddenWrite extends Error {
|
|
|
36
71
|
}
|
|
37
72
|
}
|
|
38
73
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
74
|
+
/**
|
|
75
|
+
* The sinks the driver's own free-zone policy sanctions (pmos-autonomous-drive, round 2): research,
|
|
76
|
+
* routing, the surface explainer, a disposable prototype, and DRAFT specs. Anything not here is
|
|
77
|
+
* refused — including every ledger, decision log and record nobody thought to enumerate.
|
|
78
|
+
*
|
|
79
|
+
* Directory prefixes rather than basenames, because these artifact classes name their files per
|
|
80
|
+
* initiative. `planning/evals/` admits a draft rubric and NOT `evals.jsonl`, which lives in
|
|
81
|
+
* `planning/runs/` and is refused with a reason below.
|
|
82
|
+
*/
|
|
83
|
+
const ALLOWED_DIR_PREFIXES = Object.freeze([
|
|
84
|
+
'planning/prd/',
|
|
85
|
+
'planning/evals/',
|
|
86
|
+
'planning/explainers/',
|
|
87
|
+
'planning/research/',
|
|
88
|
+
'planning/briefs/',
|
|
89
|
+
'prototype/',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Machine ledgers the driver legitimately appends to: its own run record, the verification it owes,
|
|
94
|
+
* the work-items it declares, and the friction it hits.
|
|
95
|
+
*
|
|
96
|
+
* These are BASENAMES rather than a `planning/runs/` prefix, deliberately. Allowing the directory
|
|
97
|
+
* would admit `acceptances.jsonl`, `evals.jsonl` and `outcome_checks.jsonl` by default, and would
|
|
98
|
+
* admit the FOURTH ledger somebody adds next year without anyone deciding to — which is exactly the
|
|
99
|
+
* class-failure this rewrite exists to close. A new ledger is refused until it is named here.
|
|
100
|
+
*
|
|
101
|
+
* `agent-runs.jsonl` is on this list even though it carries `pre_run_contract`: the driver logging
|
|
102
|
+
* its own run is the free zone working as designed, and a fabricated `PM_approved` INSIDE that write
|
|
103
|
+
* is caught by the content guard below, not by refusing the file.
|
|
104
|
+
*/
|
|
105
|
+
/**
|
|
106
|
+
* RESOLVE BEFORE ALLOWING — each ledger paired with the ONE directory it may live in.
|
|
107
|
+
*
|
|
108
|
+
* This was a bare basename list matched with `path.basename` in any directory, and an independent
|
|
109
|
+
* evaluator walked through it on 2026-08-04 with `/etc/agent-runs.jsonl`,
|
|
110
|
+
* `/home/user/.ssh/discoveries.md` and `workspace/lib/discoveries.md` — all permitted, because the
|
|
111
|
+
* name was the whole identity. Worse, the two halves of the allowlist disagreed: the PREFIX half
|
|
112
|
+
* correctly refused absolute paths while the basename half waved them through, so which rule
|
|
113
|
+
* happened to match decided whether `/etc/…` was inside the repo.
|
|
114
|
+
*
|
|
115
|
+
* A ledger is now its path, not its name.
|
|
116
|
+
*/
|
|
117
|
+
const ALLOWED_FILES = Object.freeze([
|
|
118
|
+
'planning/runs/agent-runs.jsonl',
|
|
119
|
+
'planning/runs/verify-queue.jsonl',
|
|
120
|
+
'planning/runs/work-items.jsonl',
|
|
121
|
+
'discoveries.md',
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
/** Kept for the refusal message only — never for deciding permission. */
|
|
125
|
+
const ALLOWED_BASENAMES = Object.freeze(ALLOWED_FILES.map((f) => path.posix.basename(f)));
|
|
126
|
+
|
|
127
|
+
/** Named explicitly so the refusal says WHY, rather than "not on the allowlist". Each of these
|
|
128
|
+
* would otherwise be caught by the allowlist anyway — the value here is the message. */
|
|
129
|
+
const HUMAN_DECISION_SINKS = Object.freeze({
|
|
130
|
+
// The module argues that a DIRECTORY prefix "would admit the FOURTH ledger somebody adds next year
|
|
131
|
+
// without anyone deciding to — exactly the class-failure this rewrite exists to close" — and then
|
|
132
|
+
// allows `planning/evals/` as a prefix, which admits the PM's own override log and any future
|
|
133
|
+
// planning/evals/*.jsonl. The argument is right; it just was not applied to its own second
|
|
134
|
+
// directory. These two are the PM's calibration record, not the driver's scratch space.
|
|
135
|
+
'eval-calibration.md': 'the PM\'s log of every override of an evaluator verdict is the PM\'s (D40)',
|
|
136
|
+
'calibration-status.md': 'the declared calibration state is the PM\'s to assert, not the driver\'s',
|
|
137
|
+
'acceptances.jsonl': 'the Acceptance Gate is the PM\'s and non-delegable (D12)',
|
|
138
|
+
'evals.jsonl': 'a Review-Gate verdict on the driver\'s own work is self-grading — the driver may not record one (D40)',
|
|
139
|
+
'outcome_checks.jsonl': 'whether the bet paid off is the PM\'s judgement (D57)',
|
|
140
|
+
'DECISIONS.md': 'an architectural decision is the PM\'s (D45)',
|
|
141
|
+
'pm-decisions.md': 'the PM\'s decision journal is the PM\'s',
|
|
142
|
+
});
|
|
41
143
|
|
|
42
|
-
/** Disposition tokens (D60)
|
|
43
|
-
|
|
144
|
+
/** Disposition tokens (D60), widened to the bare verbs bypass 4 slipped through, and anchored so a
|
|
145
|
+
* status VALUE that begins with one is a disposition while prose mentioning one is not. */
|
|
146
|
+
/* Explicit groups, not a trailing `?` on the last letter. The first attempt at this widening wrote
|
|
147
|
+
* `KILLED?`, which is "KILLE" plus an optional "D" and therefore does NOT match the bare `KILL` —
|
|
148
|
+
* reintroducing bypass 4 while appearing to fix it. Caught only because every bypass is attempted
|
|
149
|
+
* individually, which is the whole argument for the suite below. */
|
|
150
|
+
const DISPOSITION_RE = /^\s*"?\s*(PROMOTE(D)?|ITERATE(D)?|KILL(ED)?)\b/i;
|
|
44
151
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
152
|
+
/**
|
|
153
|
+
* The leading-token rule above is correct and insufficient, and the measurement says so.
|
|
154
|
+
*
|
|
155
|
+
* Anchoring to the value's first token bought the false positive back (`derived from the promoted
|
|
156
|
+
* prototype` is permitted, rightly) and paid for it in false NEGATIVES nobody counted. Swept over
|
|
157
|
+
* every disposition-bearing `status:` line in this repo's own planning/initiatives/ on 2026-08-04:
|
|
158
|
+
* **26 of 30 were permitted**, including four real recorded PM dispositions —
|
|
159
|
+
*
|
|
160
|
+
* status: "CLOSED — disposed PROMOTE by PM wawan 2026-07-28…"
|
|
161
|
+
* status: "CLOSED — PROMOTED 2026-07-28…"
|
|
162
|
+
* status: "…PM disposition 2026-07-29: ITERATE A…"
|
|
163
|
+
* status: "**PM disposition 2026-07-30: ITERATE C**"
|
|
164
|
+
*
|
|
165
|
+
* — because each leads with CLOSED, or with the word "PM". A guard validated against six test
|
|
166
|
+
* strings and never against the corpus it protects. So the leading-token rule stays, and these
|
|
167
|
+
* value-INTERNAL forms join it: a disposition announced as a disposition, wherever it sits in the
|
|
168
|
+
* line. Prose that merely mentions a verb still passes, because these require the announcing noun.
|
|
169
|
+
*/
|
|
170
|
+
const DISPOSITION_INTERNAL_RE = new RegExp(
|
|
171
|
+
'(?:' +
|
|
172
|
+
'dispositions?\\s*[:=]\\s*|' + // "disposition: PROMOTE", "PM disposition: ITERATE A"
|
|
173
|
+
'dispos(?:ed|ition)\\s+(?:to\\s+)?|' + // "disposed PROMOTE", "disposed to KILL"
|
|
174
|
+
'disposition\\s+\\S{0,24}?\\s*[:=]\\s*' + // "PM disposition 2026-07-30: ITERATE C"
|
|
175
|
+
')' +
|
|
176
|
+
'\\W{0,4}(PROMOTE(?:D)?|ITERATE(?:D)?|KILL(?:ED)?)\\b',
|
|
177
|
+
'i');
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The third rule, rewritten after independent verification refuted its first form.
|
|
181
|
+
*
|
|
182
|
+
* The first attempt keyed on CASE: dispositions are written `PROMOTED`, prose writes "the promoted
|
|
183
|
+
* prototype". Swept over the corpus it looked perfect — 7 all-caps lines, all 7 genuine, zero false
|
|
184
|
+
* positives. An independent verifier then pointed out the obvious: **a driver forging a disposition
|
|
185
|
+
* defeats a case-sensitive guard by not pressing shift.**
|
|
186
|
+
*
|
|
187
|
+
* status: CLOSED — promoted 2026-07-28 by PM wawan prev=refused → mine=PERMITTED
|
|
188
|
+
*
|
|
189
|
+
* That is `workspace-doc-editing.md`'s real recorded disposition in lower case, and it was a strict
|
|
190
|
+
* regression against the module I was fixing — the same veto I wrote the rubric to catch, in the fix
|
|
191
|
+
* for the round that failed it. It also carried the SAME `KILLED?` typo this file documents itself
|
|
192
|
+
* catching twenty lines up ("KILLE" plus an optional D never matches a bare `KILL`).
|
|
193
|
+
*
|
|
194
|
+
* So case is not the signal. The signal is GRAMMAR: a disposition is the verb used as a verb, and
|
|
195
|
+
* the false positive this round must preserve is the verb used as an ADJECTIVE or in a PASSIVE
|
|
196
|
+
* clause — "derived from *the promoted* prototype", "M3 *was killed* off upstream". Both are marked
|
|
197
|
+
* by a small closed set of words immediately before the verb. Everything else is a disposition,
|
|
198
|
+
* whatever its case.
|
|
199
|
+
*
|
|
200
|
+
* Default refuse, carve out narrowly, and enumerate the carve-out — rather than default permit and
|
|
201
|
+
* enumerate the catches, which is the denylist shape this whole round exists to remove.
|
|
202
|
+
*/
|
|
203
|
+
const DISPOSITION_VERB = '(?:PROMOTE[SD]?|ITERATE[SD]?|KILL(?:ED|S)?)';
|
|
204
|
+
|
|
205
|
+
/** Immediately-preceding words that make the verb a modifier or a passive, not an act. */
|
|
206
|
+
const PROSE_LEAD = new Set([
|
|
207
|
+
'the', 'a', 'an', 'this', 'that', 'these', 'those', 'its', 'their', 'our', 'his', 'her',
|
|
208
|
+
'was', 'were', 'been', 'being', 'is', 'are', 'be', 'get', 'gets', 'got', 'gotten',
|
|
209
|
+
'not', 'never', 'unless', 'if', 'when', 'would', 'could', 'should', 'may', 'might',
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* True when a status VALUE announces a PM disposition. Case-insensitive by construction; the
|
|
214
|
+
* carve-out, not the catch, is what is enumerated.
|
|
215
|
+
*/
|
|
216
|
+
function isDisposition(value) {
|
|
217
|
+
const v = String(value);
|
|
218
|
+
if (DISPOSITION_RE.test(v) || DISPOSITION_INTERNAL_RE.test(v)) return true;
|
|
219
|
+
const re = new RegExp(`(^|[^A-Za-z])(${DISPOSITION_VERB})\\b`, 'gi');
|
|
220
|
+
let m;
|
|
221
|
+
while ((m = re.exec(v)) !== null) {
|
|
222
|
+
const before = v.slice(0, m.index + m[1].length);
|
|
223
|
+
const prior = (before.match(/([A-Za-z']+)[^A-Za-z']*$/) || [])[1];
|
|
224
|
+
if (prior && PROSE_LEAD.has(prior.toLowerCase())) continue; // adjectival or passive — prose
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* NORMALIZE BEFORE MATCHING — and the reason this rule is written as a rule.
|
|
232
|
+
*
|
|
233
|
+
* The predecessor tested `/PM_approved\s*[:=]\s*true/i`: unbounded whitespace. Its replacement — the
|
|
234
|
+
* "hardening" — tested `/PM_approved\W{0,4}(true|1|yes)/i`, which widened the VALUE side and, without
|
|
235
|
+
* anyone noticing, capped the SEPARATOR side at four non-word characters. An independent evaluator
|
|
236
|
+
* measured both modules side by side on 2026-08-04:
|
|
237
|
+
*
|
|
238
|
+
* PM_approved : true old=refused new=PERMITTED
|
|
239
|
+
* PM_approved: true old=refused new=PERMITTED
|
|
240
|
+
*
|
|
241
|
+
* A hardening round that shipped a strict regression, on the one predicate whose whole job is to stop
|
|
242
|
+
* a driver forging the PM's approval. `{"pre_run_contract" : {"PM_approved" : true}}` written
|
|
243
|
+
* as text to the allowlisted agent-runs.jsonl JSON.parses to `PM_approved === true`, which
|
|
244
|
+
* scripts/workflow-state.js accepts as O5 satisfaction.
|
|
245
|
+
*
|
|
246
|
+
* The lesson is not "use a bigger budget". Any budget is a denylist of the gaps you thought of, and
|
|
247
|
+
* the next one is defeated by a form nobody enumerated (the double-stringified payload defeated four
|
|
248
|
+
* with five escape characters). So: collapse the input to a canonical form FIRST — drop the
|
|
249
|
+
* characters an encoding can multiply, squeeze whitespace — and only then match, with unbounded
|
|
250
|
+
* separators. There is no budget to overrun because there is nothing left to pad with.
|
|
251
|
+
*/
|
|
252
|
+
/** Returned when normalization will not settle — the call site treats it as refusal, never as text. */
|
|
253
|
+
const NON_CONVERGENT = '\u0000NON_CONVERGENT\u0000';
|
|
254
|
+
|
|
255
|
+
function normalizeForMatch(s) {
|
|
256
|
+
let out = String(s);
|
|
257
|
+
/* DECODE, do not delete. The first attempt at this stripped backslashes outright — and a literal
|
|
258
|
+
* tab, which JSON.stringify writes as the two characters `\t`, became the letter `t`:
|
|
259
|
+
*
|
|
260
|
+
* PM_approved<TAB>:<TAB>true → "PM_approved\t:\ttrue" → PM_approvedt:ttrue
|
|
261
|
+
*
|
|
262
|
+
* …which matches nothing, so a tab-separated approval walked straight through the "fix". Caught by
|
|
263
|
+
* this round's own N-R1 assertion, which is the argument for attempting each form individually.
|
|
264
|
+
* Escape sequences are DATA and must be decoded to what they denote, never erased.
|
|
265
|
+
*
|
|
266
|
+
* Applied repeatedly so that multiply-stringified payloads (the ninth bypass) unwrap layer by
|
|
267
|
+
* layer rather than one level deep. Bounded, because a fixed point is not guaranteed on hostile
|
|
268
|
+
* input and an unbounded loop here is a denial of service. */
|
|
269
|
+
/* ONE left-to-right pass per round, not four independent global replaces.
|
|
270
|
+
*
|
|
271
|
+
* The four-replace form was itself defective, and an independent verifier found it: because
|
|
272
|
+
* `JSON.stringify` DOUBLES backslashes, the `\\u....` replace consumed the second backslash and
|
|
273
|
+
* left the first dangling in front of the decoded letter, which the `\\[tnrfvb0]` replace then
|
|
274
|
+
* ATE as a control char — turning `PM_approved` into `PM_app o ed` and permitting the write. The
|
|
275
|
+
* same `\t` → `t` corruption as the first attempt, one rule to the right, because the rules could
|
|
276
|
+
* see each other's output mid-pass. A single scanner consumes each escape exactly once.
|
|
277
|
+
*
|
|
278
|
+
* Loops to a FIXED POINT rather than a fixed count. The earlier `i < 4` was a budget — and this
|
|
279
|
+
* file argues at length that a budget is a denylist of the gaps you thought of; five layers of
|
|
280
|
+
* stringification walked past it. The cap here is a non-termination guard, not a budget, and
|
|
281
|
+
* hitting it means the input is still changing after 16 rounds, which is not something a
|
|
282
|
+
* legitimate write does: it fails CLOSED at the call site. */
|
|
283
|
+
const decodeOnce = (s) => {
|
|
284
|
+
let r = '';
|
|
285
|
+
for (let i = 0; i < s.length; i++) {
|
|
286
|
+
if (s[i] !== '\\') { r += s[i]; continue; }
|
|
287
|
+
const n = s[i + 1];
|
|
288
|
+
if (n === undefined) { r += ' '; break; }
|
|
289
|
+
if (n === 'u' && /^[0-9a-fA-F]{4}$/.test(s.slice(i + 2, i + 6))) {
|
|
290
|
+
r += String.fromCharCode(parseInt(s.slice(i + 2, i + 6), 16)); i += 5;
|
|
291
|
+
} else if (n === 'x' && /^[0-9a-fA-F]{2}$/.test(s.slice(i + 2, i + 4))) {
|
|
292
|
+
r += String.fromCharCode(parseInt(s.slice(i + 2, i + 4), 16)); i += 3;
|
|
293
|
+
} else if ('tnrfvb0'.includes(n)) { r += ' '; i += 1; }
|
|
294
|
+
else { r += n; i += 1; }
|
|
295
|
+
}
|
|
296
|
+
return r;
|
|
297
|
+
};
|
|
298
|
+
let converged = false;
|
|
299
|
+
for (let i = 0; i < 16; i++) {
|
|
300
|
+
const before = out;
|
|
301
|
+
out = decodeOnce(out);
|
|
302
|
+
if (out === before) { converged = true; break; }
|
|
303
|
+
}
|
|
304
|
+
if (!converged) return NON_CONVERGENT;
|
|
305
|
+
return out
|
|
306
|
+
.replace(/["'`]/g, '') // quoting is packaging, not content
|
|
307
|
+
.replace(/\s+/g, ' '); // any run of whitespace is one space
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Unbounded separators, by construction. Applied only to normalized text. */
|
|
311
|
+
const APPROVAL_RE = /PM_approved\s*[:=]\s*(true|1|yes)\b/i;
|
|
312
|
+
|
|
313
|
+
/** Truthy-tolerant: an approval the PM never gave is what is being refused; its JSON type is not the
|
|
314
|
+
* point. Closes bypass 2. */
|
|
315
|
+
const TRUTHY = new Set([true, 1, 'true', 'TRUE', 'True', 'yes', 'YES', '1']);
|
|
316
|
+
|
|
317
|
+
const isObj = (v) => !!v && typeof v === 'object';
|
|
318
|
+
|
|
319
|
+
/** True when `value` carries a truthy PM_approved anywhere. No depth cap (bypass 1); cycle-safe. */
|
|
320
|
+
function carriesApproval(value, seen) {
|
|
321
|
+
const s = seen || new Set();
|
|
322
|
+
if (!isObj(value) || s.has(value)) return false;
|
|
323
|
+
s.add(value);
|
|
48
324
|
for (const [k, v] of Object.entries(value)) {
|
|
49
|
-
if (k === 'PM_approved' && v
|
|
50
|
-
if (
|
|
51
|
-
if (carriesApproval(v, depth + 1)) return true;
|
|
325
|
+
if (k === 'PM_approved' && TRUTHY.has(v)) return true;
|
|
326
|
+
if (carriesApproval(v, s)) return true;
|
|
52
327
|
}
|
|
53
328
|
return false;
|
|
54
329
|
}
|
|
55
330
|
|
|
331
|
+
/**
|
|
332
|
+
* True when a `status`/`disposition` key ANYWHERE carries a disposition (bypass 5).
|
|
333
|
+
*
|
|
334
|
+
* Three type-loose gaps closed here, each measured on 2026-08-04: the key test was case-SENSITIVE
|
|
335
|
+
* (`Status` walked past), the value test required `typeof v === 'string'` (so `status: ['PROMOTED']`
|
|
336
|
+
* walked past), and a Buffer serialized to a byte array invisible to every scan.
|
|
337
|
+
*/
|
|
338
|
+
function carriesDisposition(value, seen) {
|
|
339
|
+
const s = seen || new Set();
|
|
340
|
+
if (!isObj(value) || s.has(value)) return false;
|
|
341
|
+
s.add(value);
|
|
342
|
+
for (const [k, v] of Object.entries(value)) {
|
|
343
|
+
const isStatusKey = k.toLowerCase() === 'status' || k.toLowerCase() === 'disposition';
|
|
344
|
+
if (isStatusKey) {
|
|
345
|
+
// A status value may be a string, a Buffer, or a list of candidate values. All are inspected.
|
|
346
|
+
const candidates = Array.isArray(v) ? v : [v];
|
|
347
|
+
for (const c of candidates) {
|
|
348
|
+
if (typeof c === 'string' && isDisposition(c)) return true;
|
|
349
|
+
if (Buffer.isBuffer(c) && isDisposition(c.toString('utf8'))) return true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (Buffer.isBuffer(v)) continue; // a Buffer's numeric indices are not an object graph
|
|
353
|
+
if (carriesDisposition(v, s)) return true;
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Repo-relative, canonical, or nothing.
|
|
360
|
+
*
|
|
361
|
+
* `path.posix.normalize` collapses `.` and `..` properly instead of the old substring test, which
|
|
362
|
+
* both over-refused (a legitimate `draft..v2.md`) and under-thought the problem. The backslash
|
|
363
|
+
* rewrite is gone: on POSIX a backslash is an ordinary filename character, and rewriting it let the
|
|
364
|
+
* single root-level file `planning\prd\x.md` pose as a file inside `planning/prd/`.
|
|
365
|
+
*/
|
|
366
|
+
function onAllowlist(file) {
|
|
367
|
+
const raw = String(file);
|
|
368
|
+
if (path.posix.isAbsolute(raw) || path.win32.isAbsolute(raw)) return false; // never outside the repo
|
|
369
|
+
const norm = path.posix.normalize(raw.replace(/^\.\//, ''));
|
|
370
|
+
if (norm === '.' || norm.startsWith('../')) return false; // escaped the repo root
|
|
371
|
+
if (norm.split('/').includes('..')) return false;
|
|
372
|
+
if (ALLOWED_FILES.includes(norm)) return true;
|
|
373
|
+
/* The prefix half admits DRAFT ARTIFACTS, never ledgers. Without this the module's own
|
|
374
|
+
* fourth-ledger argument fails against its own second directory: `planning/evals/anything.jsonl`
|
|
375
|
+
* was permitted, so the next append-only ledger anyone puts there is admitted by default —
|
|
376
|
+
* "exactly the class-failure this rewrite exists to close", as the comment above says. */
|
|
377
|
+
if (/\.jsonl$/i.test(norm)) return false;
|
|
378
|
+
return ALLOWED_DIR_PREFIXES.some((p) => norm.startsWith(p));
|
|
379
|
+
}
|
|
380
|
+
|
|
56
381
|
/**
|
|
57
382
|
* The chokepoint. Every driver write goes through here or does not happen.
|
|
58
383
|
* @param {{file:string, payload?:any, text?:string}} req
|
|
59
384
|
* @throws {ForbiddenWrite}
|
|
60
385
|
*/
|
|
61
386
|
function assertWritable(req) {
|
|
62
|
-
|
|
63
|
-
|
|
387
|
+
// (6) FAIL CLOSED. The predecessor returned `true` here, which is the worst of the seven: a
|
|
388
|
+
// request this function cannot understand is precisely the one it must not wave through.
|
|
389
|
+
if (!isObj(req) || Array.isArray(req)) {
|
|
390
|
+
throw new ForbiddenWrite('an unreadable request', 'a write request must be an object — refused (fail-closed)');
|
|
391
|
+
}
|
|
392
|
+
if (typeof req.file !== 'string' || !req.file.trim()) {
|
|
393
|
+
throw new ForbiddenWrite('a request with no target', 'a write request must name its `file` — refused (fail-closed)');
|
|
394
|
+
}
|
|
64
395
|
|
|
65
|
-
|
|
66
|
-
|
|
396
|
+
/* A Buffer or TypedArray supplied AS the payload is bytes the writer will put on disk verbatim,
|
|
397
|
+
* and JSON.stringify renders it `{"type":"Buffer","data":[80,77,…]}` — invisible to the text scan
|
|
398
|
+
* and to the object walk alike. Both were handled NESTED and neither at the top level, which an
|
|
399
|
+
* independent verifier walked straight through with
|
|
400
|
+
* `payload: Buffer.from(JSON.stringify({pre_run_contract:{PM_approved:true}}))`. Decode to text
|
|
401
|
+
* and adjudicate it as text. */
|
|
402
|
+
if (Buffer.isBuffer(req.payload) || ArrayBuffer.isView(req.payload)) {
|
|
403
|
+
const asText = Buffer.from(
|
|
404
|
+
ArrayBuffer.isView(req.payload) && !Buffer.isBuffer(req.payload)
|
|
405
|
+
? Buffer.from(req.payload.buffer, req.payload.byteOffset, req.payload.byteLength)
|
|
406
|
+
: req.payload).toString('utf8');
|
|
407
|
+
return assertWritable({ file: req.file, text: `${req.text || ''}\n${asText}` });
|
|
67
408
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
409
|
+
|
|
410
|
+
const why = HUMAN_DECISION_SINKS[path.basename(req.file)];
|
|
411
|
+
if (why) throw new ForbiddenWrite(path.basename(req.file), why);
|
|
412
|
+
|
|
413
|
+
// (7) closed as a CLASS: anything not sanctioned is refused, named or not.
|
|
414
|
+
if (!onAllowlist(req.file)) {
|
|
415
|
+
throw new ForbiddenWrite(req.file,
|
|
416
|
+
`not on the driver's allowlist — it may write ${ALLOWED_DIR_PREFIXES.join(', ')} or ${ALLOWED_BASENAMES.join(', ')}`);
|
|
71
417
|
}
|
|
72
|
-
|
|
418
|
+
|
|
419
|
+
// (1)(2)(3) closed together by one look at the text the write would actually produce. A
|
|
420
|
+
// stringified payload, a "true", and an 11-deep nesting are all plainly visible here.
|
|
421
|
+
let serialized;
|
|
422
|
+
try {
|
|
423
|
+
serialized = JSON.stringify({ p: req.payload === undefined ? null : req.payload, t: req.text || '' });
|
|
424
|
+
} catch {
|
|
425
|
+
throw new ForbiddenWrite('an unserialisable payload',
|
|
426
|
+
'a payload that cannot be inspected cannot be permitted — refused (fail-closed)');
|
|
427
|
+
}
|
|
428
|
+
const normalized = normalizeForMatch(serialized);
|
|
429
|
+
if (normalized === NON_CONVERGENT) {
|
|
430
|
+
throw new ForbiddenWrite('an input that will not normalize',
|
|
431
|
+
'a payload whose escaping does not settle cannot be inspected — refused (fail-closed)');
|
|
432
|
+
}
|
|
433
|
+
if (APPROVAL_RE.test(normalized) || carriesApproval(req.payload)) {
|
|
73
434
|
throw new ForbiddenWrite('pre_run_contract.PM_approved',
|
|
74
|
-
'the contract is the PM\'s to approve before any build (D16
|
|
435
|
+
'the contract is the PM\'s to approve before any build (D16, AGENTS.md §4)');
|
|
75
436
|
}
|
|
76
437
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
if (
|
|
438
|
+
// (4)(5) and the false positive: matched on a status VALUE's leading token, structured or raw, so
|
|
439
|
+
// `status: "PROMOTED by PM wawan"` is refused and `status: "derived from the promoted prototype"`
|
|
440
|
+
// is not. Prose that mentions a disposition is not a disposition.
|
|
441
|
+
if (carriesDisposition(req.payload)) {
|
|
81
442
|
throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
|
|
82
443
|
}
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
444
|
+
if (typeof req.text === 'string') {
|
|
445
|
+
// Split on /\r?\n/, not '\n'. The old split left a trailing '\r' on every CRLF line, and the
|
|
446
|
+
// matcher's `$` anchor (without the `m` flag) then matched nothing at all — a CRLF document
|
|
447
|
+
// sailed through the raw-text check entirely.
|
|
448
|
+
for (const line of req.text.split(/\r?\n/)) {
|
|
449
|
+
/* Markdown prefixes are how a status line is ACTUALLY written in the driver's own sinks —
|
|
450
|
+
* `- status:`, `**status:**`, `> status:`, `| status |` — and the old anchor allowed only
|
|
451
|
+
* leading whitespace, so every one of them walked past the check. */
|
|
452
|
+
const m = /^[\s>*\-+#|]*\**\s*(?:status|disposition)\**\s*[:|]\s*(.*?)\s*\|?\s*$/i.exec(line);
|
|
453
|
+
if (m && isDisposition(m[1])) {
|
|
454
|
+
throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
|
|
455
|
+
}
|
|
456
|
+
// The raw and structured paths must agree. A JSONL line supplied as `text` was permitted while
|
|
457
|
+
// the identical object supplied as `payload` was refused — the same content, two verdicts,
|
|
458
|
+
// decided by which field it arrived in.
|
|
459
|
+
const t = line.trim();
|
|
460
|
+
if (t.startsWith('{') || t.startsWith('[')) {
|
|
461
|
+
let parsed;
|
|
462
|
+
try { parsed = JSON.parse(t); } catch { parsed = undefined; }
|
|
463
|
+
if (parsed !== undefined) {
|
|
464
|
+
if (carriesDisposition(parsed)) {
|
|
465
|
+
throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
|
|
466
|
+
}
|
|
467
|
+
if (carriesApproval(parsed)) {
|
|
468
|
+
throw new ForbiddenWrite('pre_run_contract.PM_approved',
|
|
469
|
+
'the contract is the PM\'s to approve before any build (D16, AGENTS.md §4)');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
86
474
|
}
|
|
87
475
|
return true;
|
|
88
476
|
}
|
|
@@ -95,4 +483,7 @@ function guardedWriter(write) {
|
|
|
95
483
|
};
|
|
96
484
|
}
|
|
97
485
|
|
|
98
|
-
module.exports = {
|
|
486
|
+
module.exports = {
|
|
487
|
+
assertWritable, guardedWriter, carriesApproval, carriesDisposition, ForbiddenWrite,
|
|
488
|
+
ALLOWED_DIR_PREFIXES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
|
|
489
|
+
};
|
|
@@ -859,14 +859,48 @@ function computeWorkflow(id, opts) {
|
|
|
859
859
|
// ── review ─ the Review Gate's verdict, where the lane has a rubric ──────────────────────────
|
|
860
860
|
const evals = readJsonl(path.join(L.runsDir, 'evals.jsonl')).filter((e) => e && e.initiative_id === id);
|
|
861
861
|
{
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
862
|
+
/* The rung reads the RUBRIC, not the lane.
|
|
863
|
+
*
|
|
864
|
+
* It keyed on `lane` alone, so a chore that deliberately carries a rubric could never surface
|
|
865
|
+
* its Review Gate — the ladder said "chore lane carries no rubric (D56)" while a SKIPPED verdict
|
|
866
|
+
* sat in evals.jsonl unread. That is how `guard-and-ledger-hardening`, a chore whose own record
|
|
867
|
+
* argues "blast radius overrides lane" and which carried a rubric on purpose, reached `accept`
|
|
868
|
+
* with the gate never run. The board could not show what the ledger already knew.
|
|
869
|
+
*
|
|
870
|
+
* A prototype is still never scored — D60 grills leg A as dialogue, and that is a property of
|
|
871
|
+
* the lane, not of whether someone attached a rubric. Every other lane defers to the artifact. */
|
|
872
|
+
const declaresRubric = !!String(fm.rubric_path || '').trim();
|
|
873
|
+
/* EVERY lane defers to the artifact — the first version said so in the comment and applied it
|
|
874
|
+
* only to `chore`, so a discovery or incident initiative with no rubric (legitimate under D56)
|
|
875
|
+
* showed a permanent `no Review-Gate verdict recorded`: a rung it could never satisfy. The lanes
|
|
876
|
+
* that carry no rubric by design are named once, here. */
|
|
877
|
+
const RUBRIC_OPTIONAL = ['chore', 'discovery', 'incident', 'content'];
|
|
878
|
+
if (effLane === 'prototype') {
|
|
879
|
+
steps.push(step('review', 'review', 'na', 'leg A is grilled as dialogue, never scored (D60)'));
|
|
880
|
+
} else if (RUBRIC_OPTIONAL.includes(effLane) && !declaresRubric && !evals.length) {
|
|
881
|
+
steps.push(step('review', 'review', 'na', `${effLane} lane carries no rubric (D56)`));
|
|
866
882
|
} else if (evals.length) {
|
|
867
883
|
const last = evals[evals.length - 1];
|
|
868
|
-
|
|
869
|
-
|
|
884
|
+
const verdict = String(last.verdict || '').toUpperCase();
|
|
885
|
+
const label = `${last.verdict}${last.score != null ? ` (${last.score})` : ''} — advisory until calibrated per evaluator model (D40)`;
|
|
886
|
+
/* A SKIPPED gate is NOT a satisfied gate, and a FAIL is emphatically not one.
|
|
887
|
+
*
|
|
888
|
+
* This rung rendered both as a green ✓, which is the watermelon this repo names as its own
|
|
889
|
+
* failure mode: the board said the Review Gate was done over a row that says judging never
|
|
890
|
+
* happened. Thirteen initiatives currently render `✓ review` over a SKIPPED verdict, and the
|
|
891
|
+
* fix that made a chore's gate visible ADDED one. The module's own rule, repeated in three
|
|
892
|
+
* files — "a skip is an honest RECORD that verification did not happen; it is not
|
|
893
|
+
* verification" — now applies to the surface that reports it. */
|
|
894
|
+
if (verdict === 'SKIPPED') {
|
|
895
|
+
steps.push(step('review', 'review', 'pending', label,
|
|
896
|
+
'the gate was skipped, so it is still owed — commission an independent evaluator and record a real verdict',
|
|
897
|
+
{ rule: 'R3' }));
|
|
898
|
+
} else if (verdict === 'FAIL') {
|
|
899
|
+
steps.push(step('review', 'review', 'blocked', label,
|
|
900
|
+
'the Review Gate FAILED — remediate the findings and record a new verdict', { rule: 'R3' }));
|
|
901
|
+
} else {
|
|
902
|
+
steps.push(step('review', 'review', 'done', label));
|
|
903
|
+
}
|
|
870
904
|
} else {
|
|
871
905
|
steps.push(step('review', 'review', 'pending', 'no Review-Gate verdict recorded',
|
|
872
906
|
'score the build against the rubric (build-skills/evaluator.skill), then append to evals.jsonl',
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.28.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1093,6 +1093,23 @@ export async function readDoc(path) {
|
|
|
1093
1093
|
return res.text();
|
|
1094
1094
|
}
|
|
1095
1095
|
|
|
1096
|
+
/**
|
|
1097
|
+
* panel-initiatives.js — `searchFiles(q, limit)`. Every file under THIS workspace's root whose path
|
|
1098
|
+
* or contents match, from the one endpoint that walks the folder. Bounded on the server, and the
|
|
1099
|
+
* bound comes back in the payload (`truncated`, `scanned`) rather than being silently applied — a
|
|
1100
|
+
* capped list rendered as a complete one is the defect the search box already had.
|
|
1101
|
+
*
|
|
1102
|
+
* FIXTURE mode resolves to an empty, untruncated result WITHOUT fetching: rule 1 of this file is
|
|
1103
|
+
* that a fixture render is provably not a real read wearing a costume, and `no-network.test.js`
|
|
1104
|
+
* checks that claim at source level.
|
|
1105
|
+
*/
|
|
1106
|
+
export async function searchFiles(query, limit) {
|
|
1107
|
+
const q = String(query == null ? '' : query).trim();
|
|
1108
|
+
if (FIXTURE || !q) return { ok: true, q, hits: [], truncated: false, scanned: 0 };
|
|
1109
|
+
const p = products.get(currentProductName());
|
|
1110
|
+
return getJson('/api/search/files', { ws: state.wsId || (p ? p.ws : ''), q, limit });
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1096
1113
|
/**
|
|
1097
1114
|
* tab-doc.js — `saveDoc({ws, path, base, text})`. Refuse-on-stale: the server compares `base`
|
|
1098
1115
|
* against the file on disk and REFUSES rather than overwriting a file that changed underneath.
|