create-pmos 0.31.1 → 0.32.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 +10 -0
- package/package.json +2 -2
- package/payload/KIT-VERSION +1 -1
- package/payload/scripts/anchor-resolve.js +25 -1
- package/workspace/lib/drive-guard.js +60 -18
- package/workspace/lib/engine/anchor-resolve.js +25 -1
- package/workspace/lib/sink.js +321 -0
- package/workspace/package.json +3 -3
- package/workspace/server.js +18 -1
package/README.md
CHANGED
|
@@ -232,6 +232,16 @@ npx create-pmos@latest --update
|
|
|
232
232
|
|
|
233
233
|
Refreshes the skills, concepts, and scripts only. Your records — `state/`, `planning/`, `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says exactly what you're running.
|
|
234
234
|
|
|
235
|
+
## What's new in 0.32.0
|
|
236
|
+
|
|
237
|
+
**The PM's own accept button now writes through the guarded writer, instead of appending to the acceptance log directly.** Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
238
|
+
|
|
239
|
+
- **`POST /api/gate` records your acceptance through the sink guard.** It resolves the path before allowing it, opens with `O_NOFOLLOW`, refuses a hard-linked target, writes to the descriptor it vetted rather than reopening the path afterwards, and rolls back a partial write instead of leaving half a record. It used to be a bare append — the one write path in the app that had never been hardened, while the module written for exactly this job had no caller at all.
|
|
240
|
+
- **A failed write is an HTTP 500, not a green tick.** Under D64 the file-mode acceptance log is the system of record; a swallowed append with `{ok:true}` would light the gate green over a record that never landed.
|
|
241
|
+
- **`drive-guard`'s refusal messages are read from a classification file rather than restated inside it.** If that file is absent — which it is in a plain kit install — the write is still refused; only the wording falls back to the generic message. Nothing about permission depends on a file that might not be there.
|
|
242
|
+
|
|
243
|
+
**Not in this release, said plainly.** The bigger piece of this wave — a declared registry of which records belong to the PM versus an agent, and `check-separation.js`, which names by `file:line` any module that writes one of the PM's records directly — is **PMOS-self only** for now and is not in your payload. Its paths are written for this repository's layout, and shipping them unchanged would give you a checker that reports confidently about directories you do not have. Porting it to the kit layout is its own piece of work, not a line in a release note.
|
|
244
|
+
|
|
235
245
|
## What's new in 0.31.1
|
|
236
246
|
|
|
237
247
|
**Your specs stop arriving in Jira as ragged half-lines. One command repairs the ones already written, and the workspace does it in one click.** Run `npx create-pmos@latest --update` for the command, or `npx create-pmos@latest workspace` for the button.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Scaffold the PMOS portable kit
|
|
3
|
+
"version": "0.32.0",
|
|
4
|
+
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-pmos": "bin/create-pmos.js"
|
package/payload/KIT-VERSION
CHANGED
|
@@ -196,8 +196,31 @@ function readBudgets(root) {
|
|
|
196
196
|
* `index` comes from parseOkrIndex; `budgets` from readBudgets (optional — omit to skip the
|
|
197
197
|
* registry check, which callers that only need the FORM can do).
|
|
198
198
|
*/
|
|
199
|
+
/** Strip ONE layer of matching surrounding quotes.
|
|
200
|
+
*
|
|
201
|
+
* `parent_kr` is a YAML scalar, and a value whose prose contains a colon-space MUST be quoted or
|
|
202
|
+
* the document does not parse. The flat frontmatter reader hands the value through with its quotes
|
|
203
|
+
* intact, so a legitimately-quoted anchor arrived here as `"health:security …` and matched no form.
|
|
204
|
+
*
|
|
205
|
+
* This defect occurred TWICE — `sink-discipline-backport` on 2026-08-04 and
|
|
206
|
+
* `separation-invariant-enforcement` on 2026-08-05 — and the first time it was fixed by unquoting
|
|
207
|
+
* the one file. That is the wave's own lesson landing on the person who wrote it down: the unit of
|
|
208
|
+
* attention was the file, the unit of the defect was the rule. "Do not quote it" is a rule that
|
|
209
|
+
* lives in prose and depends on an author remembering; tolerating the quoting is a rule that lives
|
|
210
|
+
* in code.
|
|
211
|
+
*
|
|
212
|
+
* Only a MATCHING outer pair, and only one layer: a stray leading quote is still malformed, and
|
|
213
|
+
* reporting it as such is the point. */
|
|
214
|
+
function unquote(s) {
|
|
215
|
+
const t = s.trim();
|
|
216
|
+
if (t.length >= 2 && ((t[0] === '"' && t[t.length - 1] === '"') || (t[0] === "'" && t[t.length - 1] === "'"))) {
|
|
217
|
+
return t.slice(1, -1).trim();
|
|
218
|
+
}
|
|
219
|
+
return t;
|
|
220
|
+
}
|
|
221
|
+
|
|
199
222
|
function resolveAnchor(raw, index, budgets) {
|
|
200
|
-
const original = String(raw == null ? '' : raw).trim();
|
|
223
|
+
const original = unquote(String(raw == null ? '' : raw).trim());
|
|
201
224
|
const base = { raw: original, kind: 'empty', status: 'empty', objective: null, kr: null, budget: null, period: null, form: null, reason: null };
|
|
202
225
|
if (!original) return Object.assign(base, { reason: 'parent_kr is empty — unanchored work is rejected (D59)' });
|
|
203
226
|
|
|
@@ -269,6 +292,7 @@ function canonical(resolved, fallbackPeriod) {
|
|
|
269
292
|
}
|
|
270
293
|
|
|
271
294
|
module.exports = {
|
|
295
|
+
unquote,
|
|
272
296
|
parseOkrIndex, readBudgets, resolveAnchor, canonical,
|
|
273
297
|
objectiveId, krId, stripCommentary, listOkrFiles,
|
|
274
298
|
ACCEPTED_FORMS,
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
* something does adopt it, it will not adopt seven bypasses with it.
|
|
62
62
|
*/
|
|
63
63
|
|
|
64
|
+
const fs = require('fs');
|
|
64
65
|
const path = require('path');
|
|
65
66
|
|
|
66
67
|
class ForbiddenWrite extends Error {
|
|
@@ -117,29 +118,69 @@ const ALLOWED_DIR_PREFIXES = Object.freeze([
|
|
|
117
118
|
const ALLOWED_FILES = Object.freeze([
|
|
118
119
|
'planning/runs/agent-runs.jsonl',
|
|
119
120
|
'planning/runs/verify-queue.jsonl',
|
|
120
|
-
|
|
121
|
+
/* `planning/work-items.jsonl`, NOT `planning/runs/work-items.jsonl`. The latter was allowlisted
|
|
122
|
+
* here for weeks and has never existed — scripts/work-item.js records that directly. An allowlist
|
|
123
|
+
* entry for a path that does not exist permits nothing and protects nothing; what it does is make
|
|
124
|
+
* the list look like it covers the factory's ledger when it does not. Found by an independent pass
|
|
125
|
+
* comparing this list against pmos/graph-membership.json, which is the whole argument for the
|
|
126
|
+
* derivation below: two enumerations of the same thing disagree, and nothing notices. */
|
|
127
|
+
'planning/work-items.jsonl',
|
|
121
128
|
'discoveries.md',
|
|
122
129
|
]);
|
|
123
130
|
|
|
124
131
|
/** Kept for the refusal message only — never for deciding permission. */
|
|
125
132
|
const ALLOWED_BASENAMES = Object.freeze(ALLOWED_FILES.map((f) => path.posix.basename(f)));
|
|
126
133
|
|
|
127
|
-
/**
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
134
|
+
/** ── DERIVED FROM THE REGISTRY, NOT RESTATED ────────────────────────────────────────────────────
|
|
135
|
+
*
|
|
136
|
+
* This was an inline map of seven basenames to reasons — a second enumeration of exactly the
|
|
137
|
+
* control-graph classification that `pmos/graph-membership.json` holds, with the D-numbers mirrored
|
|
138
|
+
* in both. Two copies of a rule are two places for it to be wrong, and these two ALREADY disagreed:
|
|
139
|
+
* the allowlist above named a ledger that has never existed while the registry named the real one.
|
|
140
|
+
*
|
|
141
|
+
* So the reasons come from the registry's `why` for every record it classifies `control`. Keyed by
|
|
142
|
+
* basename, because that is what the refusal path looks up.
|
|
143
|
+
*
|
|
144
|
+
* FAIL-OPEN IS NOT A RISK HERE, and it is worth saying why rather than trusting it: this map only
|
|
145
|
+
* supplies the REASON in a refusal message. Permission is decided by `onAllowlist` below, which
|
|
146
|
+
* refuses everything not sanctioned whether or not a reason was found. If the registry is missing —
|
|
147
|
+
* a vendored kit, a partial checkout — the write is still refused; the message is just the generic
|
|
148
|
+
* one. A guard whose enforcement depended on a file it might not have would be a different and much
|
|
149
|
+
* worse design. */
|
|
150
|
+
let REGISTRY_UNAVAILABLE = null;
|
|
151
|
+
function loadHumanDecisionSinks() {
|
|
152
|
+
const out = {};
|
|
153
|
+
try {
|
|
154
|
+
/* TWO LAYOUTS, the same two-path resolution scripts/md-newlines.js uses for its normalizer.
|
|
155
|
+
*
|
|
156
|
+
* In a checkout this file is `<repo>/workspace/lib/`, so the registry is two levels up. In an
|
|
157
|
+
* npm install the workspace app is bundled BESIDE the vendored kit, not inside it, so the
|
|
158
|
+
* registry — if the adopter has one — is `<install>/pmos/graph-membership.json`. Neither path
|
|
159
|
+
* is guaranteed, and that is fine: this map only supplies the REASON in a refusal message.
|
|
160
|
+
* The write is refused either way by `onAllowlist` below. */
|
|
161
|
+
const tried = [
|
|
162
|
+
path.join(__dirname, '..', '..', 'pmos', 'graph-membership.json'), // a checkout
|
|
163
|
+
path.join(__dirname, '..', '..', '..', 'pmos', 'graph-membership.json'), // beside a vendored kit
|
|
164
|
+
];
|
|
165
|
+
const found = tried.find((f) => { try { return fs.statSync(f).isFile(); } catch { return false; } });
|
|
166
|
+
if (!found) throw new Error(`no graph-membership.json in: ${tried.join(', ')}`);
|
|
167
|
+
const raw = fs.readFileSync(found, 'utf8');
|
|
168
|
+
for (const r of (JSON.parse(raw).records || [])) {
|
|
169
|
+
if (!r || r.graph !== 'control' || typeof r.path !== 'string' || r.path.endsWith('/')) continue;
|
|
170
|
+
const base = path.posix.basename(r.path);
|
|
171
|
+
if (ALLOWED_BASENAMES.includes(base)) continue; // the driver's own sanctioned ledgers
|
|
172
|
+
if (!out[base]) out[base] = String(r.why || 'a control-graph record — the PM\'s, not the driver\'s');
|
|
173
|
+
}
|
|
174
|
+
} catch (e) {
|
|
175
|
+
/* RECORDED, not swallowed. The first version caught silently, and a missing `require('fs')` in
|
|
176
|
+
* this very file turned into an empty map with no signal at all — every reasoned refusal quietly
|
|
177
|
+
* became the generic one and two suite assertions were the only thing that noticed. A catch that
|
|
178
|
+
* hides its own cause is how a degradation becomes permanent. */
|
|
179
|
+
REGISTRY_UNAVAILABLE = e && e.message ? e.message : String(e);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
const HUMAN_DECISION_SINKS = Object.freeze(loadHumanDecisionSinks());
|
|
143
184
|
|
|
144
185
|
/** Disposition tokens (D60), widened to the bare verbs bypass 4 slipped through, and anchored so a
|
|
145
186
|
* status VALUE that begins with one is a disposition while prose mentioning one is not. */
|
|
@@ -530,5 +571,6 @@ function guardedWriter(write) {
|
|
|
530
571
|
|
|
531
572
|
module.exports = {
|
|
532
573
|
assertWritable, guardedWriter, carriesApproval, carriesDisposition, isDisposition, PROSE_LEAD, ForbiddenWrite,
|
|
533
|
-
ALLOWED_DIR_PREFIXES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
|
|
574
|
+
ALLOWED_DIR_PREFIXES, ALLOWED_FILES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
|
|
575
|
+
registryUnavailable: () => REGISTRY_UNAVAILABLE,
|
|
534
576
|
};
|
|
@@ -196,8 +196,31 @@ function readBudgets(root) {
|
|
|
196
196
|
* `index` comes from parseOkrIndex; `budgets` from readBudgets (optional — omit to skip the
|
|
197
197
|
* registry check, which callers that only need the FORM can do).
|
|
198
198
|
*/
|
|
199
|
+
/** Strip ONE layer of matching surrounding quotes.
|
|
200
|
+
*
|
|
201
|
+
* `parent_kr` is a YAML scalar, and a value whose prose contains a colon-space MUST be quoted or
|
|
202
|
+
* the document does not parse. The flat frontmatter reader hands the value through with its quotes
|
|
203
|
+
* intact, so a legitimately-quoted anchor arrived here as `"health:security …` and matched no form.
|
|
204
|
+
*
|
|
205
|
+
* This defect occurred TWICE — `sink-discipline-backport` on 2026-08-04 and
|
|
206
|
+
* `separation-invariant-enforcement` on 2026-08-05 — and the first time it was fixed by unquoting
|
|
207
|
+
* the one file. That is the wave's own lesson landing on the person who wrote it down: the unit of
|
|
208
|
+
* attention was the file, the unit of the defect was the rule. "Do not quote it" is a rule that
|
|
209
|
+
* lives in prose and depends on an author remembering; tolerating the quoting is a rule that lives
|
|
210
|
+
* in code.
|
|
211
|
+
*
|
|
212
|
+
* Only a MATCHING outer pair, and only one layer: a stray leading quote is still malformed, and
|
|
213
|
+
* reporting it as such is the point. */
|
|
214
|
+
function unquote(s) {
|
|
215
|
+
const t = s.trim();
|
|
216
|
+
if (t.length >= 2 && ((t[0] === '"' && t[t.length - 1] === '"') || (t[0] === "'" && t[t.length - 1] === "'"))) {
|
|
217
|
+
return t.slice(1, -1).trim();
|
|
218
|
+
}
|
|
219
|
+
return t;
|
|
220
|
+
}
|
|
221
|
+
|
|
199
222
|
function resolveAnchor(raw, index, budgets) {
|
|
200
|
-
const original = String(raw == null ? '' : raw).trim();
|
|
223
|
+
const original = unquote(String(raw == null ? '' : raw).trim());
|
|
201
224
|
const base = { raw: original, kind: 'empty', status: 'empty', objective: null, kr: null, budget: null, period: null, form: null, reason: null };
|
|
202
225
|
if (!original) return Object.assign(base, { reason: 'parent_kr is empty — unanchored work is rejected (D59)' });
|
|
203
226
|
|
|
@@ -269,6 +292,7 @@ function canonical(resolved, fallbackPeriod) {
|
|
|
269
292
|
}
|
|
270
293
|
|
|
271
294
|
module.exports = {
|
|
295
|
+
unquote,
|
|
272
296
|
parseOkrIndex, readBudgets, resolveAnchor, canonical,
|
|
273
297
|
objectiveId, krId, stripCommentary, listOkrFiles,
|
|
274
298
|
ACCEPTED_FORMS,
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* sink.js — the ONE place the append-only ledger discipline lives.
|
|
4
|
+
*
|
|
5
|
+
* ── WHY THIS FILE EXISTS ────────────────────────────────────────────────────────────────────────
|
|
6
|
+
* `record-eval.js` was PMOS's first validated writer. Three independent passes found four defects in
|
|
7
|
+
* it, and all four were fixed IN THAT FILE:
|
|
8
|
+
*
|
|
9
|
+
* 1. the sink was a caller-supplied allowlist -> a caller could name its own destination
|
|
10
|
+
* 2. the file was opened before validation -> N refused writes leaked N descriptors to EMFILE
|
|
11
|
+
* 3. a refused write still created the sink -> refusing to write left the file behind
|
|
12
|
+
* 4. the symlink check was a path-level stat -> a hardlink walked straight through it
|
|
13
|
+
*
|
|
14
|
+
* Two writers built afterwards inherited the fixes BY HAND, and each inheritance read as evidence
|
|
15
|
+
* that the pattern was holding. It was not holding — it was propagating FORWARD ONLY. The two
|
|
16
|
+
* writers that already existed when the defects were found were never revisited, because the process
|
|
17
|
+
* asks "does the new thing follow the rule?" and never "where else does this rule already live?"
|
|
18
|
+
*
|
|
19
|
+
* Measured by attempted write on 2026-08-04:
|
|
20
|
+
*
|
|
21
|
+
* scripts/work-item.js -> wrote 198 bytes through a symlink into a decoy
|
|
22
|
+
* scripts/verify-queue.js -> the same, AND wrote 202 bytes into a fresh tmpdir, because its
|
|
23
|
+
* allowlist compared `path.basename` alone. A basename is not a
|
|
24
|
+
* location; every directory on disk has one.
|
|
25
|
+
*
|
|
26
|
+
* Both are merged, PM-accepted, and load-bearing for D65 steps 1 and 2. So the discipline moves here
|
|
27
|
+
* and the writers call it. One wrong place that every assertion points at beats five where three
|
|
28
|
+
* happen to be right.
|
|
29
|
+
*
|
|
30
|
+
* ── THE RULE, STATED ONCE ───────────────────────────────────────────────────────────────────────
|
|
31
|
+
* Resolve before allowing. Validate before opening. Write to the descriptor that was vetted.
|
|
32
|
+
*
|
|
33
|
+
* The third clause is the one that looks redundant and is not. A path checked and then re-opened by
|
|
34
|
+
* path is two operations on two objects: whatever was approved may not be what gets written. The fd
|
|
35
|
+
* IS the approval, so it is what the caller must be handed.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
const path = require('path');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* THE `ForbiddenWrite`. One class, exported, re-exported by every writer that uses this guard.
|
|
43
|
+
*
|
|
44
|
+
* There were five of these — one per writer that had a guard at all — and that is not a cosmetic duplication. Callers and
|
|
45
|
+
* assertions tell "refused on purpose" from "crashed" with `instanceof`, so a guard throwing its own
|
|
46
|
+
* private copy makes every one of those checks silently false: `scripts/tests/work-item.sh` reported
|
|
47
|
+
* `threw ForbiddenWrite, want ForbiddenWrite` on seventeen assertions the moment this module started
|
|
48
|
+
* throwing a second class with the same name. A refusal nobody can recognise as a refusal is not one.
|
|
49
|
+
*
|
|
50
|
+
* `detail` is always set (null when absent) rather than conditionally attached, because a property
|
|
51
|
+
* that sometimes exists is a second shape for callers to handle.
|
|
52
|
+
*/
|
|
53
|
+
class ForbiddenWrite extends Error {
|
|
54
|
+
constructor(message, detail) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = 'ForbiddenWrite';
|
|
57
|
+
this.detail = detail || null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve a path through its real directory. Kept separate so the sink and the target are compared
|
|
62
|
+
* the same way — comparing a resolved target against an unresolved sink is a mismatch by
|
|
63
|
+
* construction, and would refuse every legitimate write on a machine where /tmp is a symlink. */
|
|
64
|
+
function resolveThrough(p) {
|
|
65
|
+
return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build a guard bound to ONE sink.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} spec
|
|
72
|
+
* @param {string} spec.sink absolute path of the only file this guard will ever open.
|
|
73
|
+
* @param {string} spec.what noun for error messages, e.g. 'work-item events'.
|
|
74
|
+
* @returns {{ open: function, sinkPath: function, __setSinkForTests: function }}
|
|
75
|
+
*
|
|
76
|
+
* The sink is a CONSTRUCTION-TIME constant, never an argument to `open`. That is defect 1 of the
|
|
77
|
+
* original four, and re-admitting it as "just a parameter" is how it would come back.
|
|
78
|
+
*/
|
|
79
|
+
function createSinkGuard(spec) {
|
|
80
|
+
if (!spec || typeof spec.sink !== 'string' || !spec.sink) {
|
|
81
|
+
throw new ForbiddenWrite('a sink guard needs a declared `sink` path — refused (fail-closed)');
|
|
82
|
+
}
|
|
83
|
+
const what = spec.what || 'records';
|
|
84
|
+
const declared = spec.sink;
|
|
85
|
+
let overrideForTests = null;
|
|
86
|
+
|
|
87
|
+
/* A seam, not a door. Gated so it cannot be reached in a production process even if something
|
|
88
|
+
* calls it — the point of A4 is that no code path lets a caller choose the destination. */
|
|
89
|
+
function __setSinkForTests(p) {
|
|
90
|
+
/* An explicit allowed set. `!process.env.PMOS_TEST` treats the STRING '0' as truthy, so
|
|
91
|
+
* PMOS_TEST=0 — the natural way an operator turns test mode OFF — OPENED the production sink
|
|
92
|
+
* override instead of closing it. A flag whose "off" value means "on" is worse than no flag. */
|
|
93
|
+
const on = (v) => v === '1' || v === 'true' || v === 'yes';
|
|
94
|
+
if (process.env.NODE_ENV !== 'test' && !on(process.env.PMOS_TEST)) {
|
|
95
|
+
throw new ForbiddenWrite('__setSinkForTests is a test seam — set NODE_ENV=test or PMOS_TEST=1 to use it');
|
|
96
|
+
}
|
|
97
|
+
overrideForTests = p;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sinkPath = () => overrideForTests || declared;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pre-flight: validate the record and confirm the target RESOLVES to the sink — without opening
|
|
104
|
+
* anything. For callers that want to ask "would this be permitted?" without a side effect.
|
|
105
|
+
*
|
|
106
|
+
* NOT AUTHORITATIVE, and the gap is deliberate rather than an oversight. Whether the sink is a
|
|
107
|
+
* symlink or carries a second hard link cannot be established without a descriptor, and a `lstat`
|
|
108
|
+
* here would answer about a path that may differ from the file eventually opened — which is the
|
|
109
|
+
* check-then-write race this module exists to close. `open` is the authority; this is a courtesy.
|
|
110
|
+
* The suite asserts the difference: a symlinked sink passes `check` and is refused by `open`.
|
|
111
|
+
*/
|
|
112
|
+
function check(targetPath, validate) {
|
|
113
|
+
if (typeof targetPath !== 'string' || !targetPath) {
|
|
114
|
+
throw new ForbiddenWrite(`no target path supplied for ${what} — refused (fail-closed)`);
|
|
115
|
+
}
|
|
116
|
+
if (typeof validate === 'function') {
|
|
117
|
+
const v = validate();
|
|
118
|
+
if (!v || !v.ok) {
|
|
119
|
+
const errs = (v && v.errors) || ['invalid'];
|
|
120
|
+
throw new ForbiddenWrite(`invalid ${what} — refused: ${errs.join('; ')}`, { errors: errs });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
let resolved;
|
|
124
|
+
try { resolved = resolveThrough(targetPath); }
|
|
125
|
+
catch { throw new ForbiddenWrite(`the sink directory does not resolve for '${targetPath}' — refused (fail-closed)`); }
|
|
126
|
+
let sinkResolved = sinkPath();
|
|
127
|
+
try { sinkResolved = resolveThrough(sinkPath()); } catch { /* not created yet */ }
|
|
128
|
+
if (resolved !== sinkResolved) {
|
|
129
|
+
throw new ForbiddenWrite(
|
|
130
|
+
`${what} are written only to ${sinkResolved}; '${targetPath}' resolves to '${resolved}'`,
|
|
131
|
+
{ targetPath, resolved, sink: sinkResolved });
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Vet `targetPath` against the declared sink and return a descriptor open for append.
|
|
138
|
+
*
|
|
139
|
+
* The caller MUST close it. Everything that can refuse, refuses before the file is opened, so a
|
|
140
|
+
* refusal creates nothing and leaks nothing.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} targetPath where the caller believes it is writing.
|
|
143
|
+
* @param {function} [validate] returns { ok, errors } — runs BEFORE the open (defects 2 and 3).
|
|
144
|
+
* @returns {number} a vetted file descriptor.
|
|
145
|
+
*/
|
|
146
|
+
function open(targetPath, validate) {
|
|
147
|
+
if (typeof targetPath !== 'string' || !targetPath) {
|
|
148
|
+
throw new ForbiddenWrite(`no target path supplied for ${what} — refused (fail-closed)`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* BEFORE the open. Ordering is the whole of defects 2 and 3: validating after opening leaks a
|
|
152
|
+
* descriptor per refusal, and O_CREAT means the refused write has already made the file. */
|
|
153
|
+
if (typeof validate === 'function') {
|
|
154
|
+
const v = validate();
|
|
155
|
+
if (!v || !v.ok) {
|
|
156
|
+
const errs = (v && v.errors) || ['invalid'];
|
|
157
|
+
throw new ForbiddenWrite(`invalid ${what} — refused: ${errs.join('; ')}`, { errors: errs });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let resolved;
|
|
162
|
+
try {
|
|
163
|
+
resolved = resolveThrough(targetPath);
|
|
164
|
+
} catch {
|
|
165
|
+
throw new ForbiddenWrite(`the sink directory does not resolve for '${targetPath}' — refused (fail-closed)`);
|
|
166
|
+
}
|
|
167
|
+
let sinkResolved = sinkPath();
|
|
168
|
+
try { sinkResolved = resolveThrough(sinkPath()); } catch { /* not created yet — compare literally */ }
|
|
169
|
+
|
|
170
|
+
/* A RESOLVED comparison, not a basename one. `verify-queue.js` compared basenames and therefore
|
|
171
|
+
* accepted every directory on disk that happened to contain the right filename. */
|
|
172
|
+
if (resolved !== sinkResolved) {
|
|
173
|
+
throw new ForbiddenWrite(
|
|
174
|
+
`${what} are written only to ${sinkResolved}; '${targetPath}' resolves to '${resolved}'`,
|
|
175
|
+
{ targetPath, resolved, sink: sinkResolved });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let fd;
|
|
179
|
+
try {
|
|
180
|
+
fd = fs.openSync(
|
|
181
|
+
resolved,
|
|
182
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND
|
|
183
|
+
| fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
|
|
184
|
+
0o644);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
/* O_NOFOLLOW turns "the sink is a symlink" into ELOOP here rather than a silent write to
|
|
187
|
+
* wherever it pointed. That is defect 4, and it is refused at open rather than by a stat that
|
|
188
|
+
* a hardlink does not answer.
|
|
189
|
+
*
|
|
190
|
+
* O_NONBLOCK is not about performance. Opening a FIFO for writing BLOCKS until a reader
|
|
191
|
+
* arrives, so a named pipe left at the sink path hangs the writer forever — found by the
|
|
192
|
+
* assertion below, which hung this suite past its timeout instead of failing. A refusal that
|
|
193
|
+
* never returns is worse than a wrong answer: nothing reports it and nothing times it out.
|
|
194
|
+
* With O_NONBLOCK the same case returns ENXIO immediately and lands in the refusal path, and
|
|
195
|
+
* on a regular file the flag does nothing at all. */
|
|
196
|
+
throw new ForbiddenWrite(
|
|
197
|
+
`cannot open the ${what} ledger at '${targetPath}' safely (${e.code || e.message}) — refused (fail-closed)`,
|
|
198
|
+
{ code: e.code });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
/* fstat on the FD, not stat on the path: for the FINAL component this genuinely closes the
|
|
203
|
+
* check-then-open race — an independent pass swapped the target inside the resolve→open window
|
|
204
|
+
* with perfect timing and got ELOOP, nlink=2 and a refusal every time.
|
|
205
|
+
*
|
|
206
|
+
* It does NOT cover the PARENT directory. `resolveThrough` realpaths the directory and
|
|
207
|
+
* `openSync` then re-walks that resolved path, so a local process able to rename the sink's
|
|
208
|
+
* parent and drop a directory symlink in its place inside that window can redirect the append
|
|
209
|
+
* — measured, 195 bytes into a foreign directory. That needs a concurrently-racing local
|
|
210
|
+
* attacker with write access to the parent, a strictly larger capability than the symlink
|
|
211
|
+
* defect this module was built to fix, so it is stated rather than claimed away. Closing it
|
|
212
|
+
* properly needs openat-style descent, which Node does not expose. The header's "the fd IS the
|
|
213
|
+
* approval" is true of the file, not of the path walked to reach it. */
|
|
214
|
+
const st = fs.fstatSync(fd);
|
|
215
|
+
if (!st.isFile()) {
|
|
216
|
+
throw new ForbiddenWrite(`'${targetPath}' is not a regular file — refused (fail-closed)`);
|
|
217
|
+
}
|
|
218
|
+
if (st.nlink !== 1) {
|
|
219
|
+
throw new ForbiddenWrite(
|
|
220
|
+
`'${targetPath}' has ${st.nlink} hard links; a ledger with a second name is not one this ` +
|
|
221
|
+
'writer controls — refused (fail-closed)', { nlink: st.nlink });
|
|
222
|
+
}
|
|
223
|
+
} catch (e) {
|
|
224
|
+
fs.closeSync(fd);
|
|
225
|
+
throw e;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return fd;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The whole write, start to finish: vet, append one JSON line, close. Callers should prefer this
|
|
233
|
+
* over `open` — it is the shape that cannot forget to close, and every existing writer's append is
|
|
234
|
+
* exactly one line.
|
|
235
|
+
*/
|
|
236
|
+
function appendLine(targetPath, record, validate) {
|
|
237
|
+
const fd = open(targetPath, validate);
|
|
238
|
+
const line = JSON.stringify(record) + '\n';
|
|
239
|
+
const want = Buffer.byteLength(line);
|
|
240
|
+
try {
|
|
241
|
+
/* THE RETURN VALUE IS NOT OPTIONAL. This was `fs.writeSync(fd, line)` with the result
|
|
242
|
+
* discarded, which reports a SHORT WRITE as success. Forced on a 64K tmpfs: a normal-shaped
|
|
243
|
+
* record returned true having appended a 3901-byte fragment with no trailing newline, and the
|
|
244
|
+
* next append CONCATENATED onto it — so one ENOSPC destroys two records and leaves a single
|
|
245
|
+
* line that no reader can parse. This is the append-only system of record, every reader
|
|
246
|
+
* JSON.parses line by line, and all six writers route through this one call.
|
|
247
|
+
*
|
|
248
|
+
* Loop, because a partial write is not necessarily an error — then verify the total. A torn
|
|
249
|
+
* tail is worse than a refused write, so if the bytes did not all land, say so loudly rather
|
|
250
|
+
* than returning true over a corrupted ledger. */
|
|
251
|
+
/* The size BEFORE this append, taken from the vetted fd. This is what makes the failure path a
|
|
252
|
+
* real refusal instead of a report: if the write tears, the file is truncated back to here and
|
|
253
|
+
* the ledger is left exactly as it was found. `nlink === 1` was already proven above, so no
|
|
254
|
+
* other name for this file exists and the rollback cannot reach anyone else's data. */
|
|
255
|
+
const before = fs.fstatSync(fd).size;
|
|
256
|
+
const buf = Buffer.from(line, 'utf8');
|
|
257
|
+
let written = 0;
|
|
258
|
+
let cause = null;
|
|
259
|
+
try {
|
|
260
|
+
while (written < want) {
|
|
261
|
+
const n = fs.writeSync(fd, buf, written, want - written);
|
|
262
|
+
if (!(n > 0)) break; // no progress: stop rather than spin
|
|
263
|
+
written += n;
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
/* ENOSPC does NOT arrive as a short write. Measured on a full tmpfs: the first writeSync
|
|
267
|
+
* short-writes 376 of 440 bytes with no error and the SECOND one throws — so a version that
|
|
268
|
+
* only checked `written !== want` never reached its own error path, and a raw Error escaped
|
|
269
|
+
* with name 'Error'. Everything in this repo tells a deliberate refusal from a crash with
|
|
270
|
+
* `instanceof ForbiddenWrite` (workspace/test.js:1772 checks e.name), so an unwrapped throw
|
|
271
|
+
* on the worst outcome this module has is the one place that signal must not be missing. */
|
|
272
|
+
cause = e;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (cause || written !== want) {
|
|
276
|
+
/* THE ROLLBACK MUST NOT DESTROY SOMEONE ELSE'S RECORD, and the first version did.
|
|
277
|
+
*
|
|
278
|
+
* It called ftruncateSync(fd, before) unconditionally. `before` was measured at the top of
|
|
279
|
+
* this function, so ANY append by another process in between — a complete, successful,
|
|
280
|
+
* returned-true append — was inside the range being truncated away. Measured by an
|
|
281
|
+
* independent pass across 5 of 5 rounds: records whose write returned `true` were missing
|
|
282
|
+
* from disk afterwards, and the ledger was NUL-padded. A rollback that eats a committed
|
|
283
|
+
* record is worse than the torn line it was added to prevent.
|
|
284
|
+
*
|
|
285
|
+
* O_APPEND makes the arithmetic decidable: our bytes are the last `written` of the file if
|
|
286
|
+
* and only if nobody else appended. So truncate ONLY when the file is exactly the size our
|
|
287
|
+
* own write would have made it. Otherwise the tail is not ours to remove, and the honest
|
|
288
|
+
* outcome is to say the ledger is torn rather than to fix it by deleting a stranger's data. */
|
|
289
|
+
let rolledBack = false;
|
|
290
|
+
let intervened = false;
|
|
291
|
+
try {
|
|
292
|
+
const now = fs.fstatSync(fd).size;
|
|
293
|
+
if (now === before + written) { fs.ftruncateSync(fd, before); rolledBack = true; }
|
|
294
|
+
else intervened = true; // another writer appended; the tail is not ours
|
|
295
|
+
} catch { /* reported below */ }
|
|
296
|
+
|
|
297
|
+
const detail = { written, want, before, rolledBack, intervened, code: cause && cause.code };
|
|
298
|
+
throw new ForbiddenWrite(
|
|
299
|
+
`partial append to the ${what} ledger: ${written} of ${want} bytes landed` +
|
|
300
|
+
(cause ? ` (${cause.code || cause.message})` : '') + '. ' +
|
|
301
|
+
(rolledBack
|
|
302
|
+
? 'The ledger was truncated back to its pre-write size, so nothing was written and the ' +
|
|
303
|
+
'next append will not concatenate onto a fragment.'
|
|
304
|
+
: intervened
|
|
305
|
+
? 'NOT ROLLED BACK ON PURPOSE — another writer appended while this write was failing, ' +
|
|
306
|
+
'so the bytes at the end of the file are not this one\'s to remove. The ledger holds ' +
|
|
307
|
+
'a torn fragment that must be repaired by hand; truncating it here would have ' +
|
|
308
|
+
'deleted a record that committed successfully.'
|
|
309
|
+
: 'THE ROLLBACK ALSO FAILED — the ledger now ends in a torn line with no trailing ' +
|
|
310
|
+
'newline, and the next append WILL concatenate onto it, destroying a second record. ' +
|
|
311
|
+
'Repair it by hand before writing again.'),
|
|
312
|
+
detail);
|
|
313
|
+
}
|
|
314
|
+
} finally { fs.closeSync(fd); }
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { check, open, appendLine, sinkPath, __setSinkForTests };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = { createSinkGuard, ForbiddenWrite, resolveThrough };
|
package/workspace/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "PMOS Terminal Workspace
|
|
5
|
+
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
8
|
"pmos": "bin/pmos.js"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"playwright-core": "^1.61.1",
|
|
30
30
|
"ws": "^8.18.0"
|
|
31
31
|
},
|
|
32
|
-
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs
|
|
32
|
+
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs — it just stops measuring pixels, and says so.",
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"pixelmatch": "^7.1.0",
|
|
35
35
|
"pngjs": "^7.0.0"
|
package/workspace/server.js
CHANGED
|
@@ -61,6 +61,7 @@ function ensureSpawnHelperExecutable() {
|
|
|
61
61
|
ensureSpawnHelperExecutable();
|
|
62
62
|
|
|
63
63
|
const { checkSurface } = require('./lib/surface-contract');
|
|
64
|
+
const { createSinkGuard } = require('./lib/sink.js');
|
|
64
65
|
const scheduleLogic = require('./lib/schedule-logic');
|
|
65
66
|
const harness = require('./lib/harness-parse');
|
|
66
67
|
const transcript = require('./lib/transcript-read');
|
|
@@ -4678,9 +4679,25 @@ async function handleRequest(req, res) {
|
|
|
4678
4679
|
// A swallowed append + {ok:true} would light the gate green over a record that never landed —
|
|
4679
4680
|
// exactly the false-green class D64 exists to delete. On write failure return HTTP 500 and
|
|
4680
4681
|
// {ok:false}, and do NOT report success. The error text carries no header/secret content (D08).
|
|
4682
|
+
/* THROUGH THE GUARD, not a bare append.
|
|
4683
|
+
*
|
|
4684
|
+
* This was `fs.appendFileSync(sink, ...)`. An independent pass attacked it through this very
|
|
4685
|
+
* endpoint's write path: **42 bytes landed through a symlink and it returned {ok:true}**. And
|
|
4686
|
+
* 37 of 49 live acceptance rows carry a `workspace` key that only THIS endpoint writes — so the
|
|
4687
|
+
* guarded writer (scripts/record-acceptance.js) was the one nothing used, while the unguarded
|
|
4688
|
+
* one had written three quarters of the system of record.
|
|
4689
|
+
*
|
|
4690
|
+
* The guard is constructed per resolved sink rather than as one module constant, because the
|
|
4691
|
+
* acceptance ledger legitimately moves with the workspace — kit layout, or the install's own
|
|
4692
|
+
* state/ on fallback. The allowlist therefore degenerates here (target === declared by
|
|
4693
|
+
* construction) and that is stated rather than glossed: what this buys is O_NOFOLLOW, the
|
|
4694
|
+
* `nlink === 1` check, and the torn-append rollback, which is precisely the attack that landed.
|
|
4695
|
+
*
|
|
4696
|
+
* `pm_label` is NOT validated against record-acceptance.js's stricter schema: that writer
|
|
4697
|
+
* REQUIRES the label, this surface must not, and requiring it here would block the PM's click. */
|
|
4681
4698
|
try {
|
|
4682
4699
|
if (fallback) fs.mkdirSync(LEGACY_STATE, { recursive: true }); // the install's state/ may not exist yet
|
|
4683
|
-
|
|
4700
|
+
createSinkGuard({ sink, what: 'PM acceptances' }).appendLine(sink, rec);
|
|
4684
4701
|
} catch (e) {
|
|
4685
4702
|
warnWriteFail('acceptances.jsonl (Acceptance Gate — system of record)', e);
|
|
4686
4703
|
return json(res, { ok: false, error: 'could not record the acceptance — the file-mode system of record is unwritable; the decision was NOT saved', sink, fallback }, 500);
|