@sleepy_panda_srl/portulan 0.1.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/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- package/spec/workspace.schema.json +438 -0
package/cli/compile.mjs
ADDED
|
@@ -0,0 +1,2209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `compile` — the enforcement compiler.
|
|
3
|
+
//
|
|
4
|
+
// node cli/compile.mjs [--workspace <dir>] [--check] [--matrix]
|
|
5
|
+
//
|
|
6
|
+
// Exit 0 wrote (or, with --check, agrees) · 1 an artifact has drifted · 2 could not run.
|
|
7
|
+
//
|
|
8
|
+
// Reads a workspace's gate policy (`gates.json`) and emits enforcement, for two backends: the Claude
|
|
9
|
+
// Code host (`permissions` + `hooks`) and the GitHub repository ruleset that is the platform floor.
|
|
10
|
+
// `--matrix` prints every rule against both. The tier vocabulary and the action vocabulary are the
|
|
11
|
+
// WORKSPACE's, never a host's — a rule says `{"shell": "git push"}`, not `Bash(git push:*)` — which is
|
|
12
|
+
// what let the second backend translate the same policy instead of forcing every adopter to rewrite
|
|
13
|
+
// theirs. That separation is the whole of "LLM-agnostic by construction" (../docs/vision.md) at this
|
|
14
|
+
// layer, and the second backend is where it stopped being a claim.
|
|
15
|
+
//
|
|
16
|
+
// ## Two layers, and which one is load-bearing
|
|
17
|
+
//
|
|
18
|
+
// Every gate is emitted TWICE: as a permission rule and as a hook. That is not belt-and-braces for
|
|
19
|
+
// its own sake — it was measured. On CLI 2.1.220 a hook that CRASHES fails OPEN: the tool runs
|
|
20
|
+
// normally, on the identical wiring that blocked when the hook was healthy. A permission rule does
|
|
21
|
+
// not fail open. So the permission rule is the gate and the hook is the sentence a human reads;
|
|
22
|
+
// a design resting on hooks alone would be a gate that a syntax error silently removes.
|
|
23
|
+
//
|
|
24
|
+
// ## Why `ask` rather than `deny` for the Gated tier
|
|
25
|
+
//
|
|
26
|
+
// ../.portulan/gate-map.md defines Gated as "explicit human approval, per action, before it
|
|
27
|
+
// happens" — which is what `ask` is: interactively it prompts; headless, where nobody can approve,
|
|
28
|
+
// it blocks. Compiling Gated to `deny` would be the *prohibition* semantics wearing the Gated
|
|
29
|
+
// tier's name, and it would make the tier above it — the constitution, which has no approval path
|
|
30
|
+
// at all — indistinguishable from an ordinary push.
|
|
31
|
+
//
|
|
32
|
+
// ## What this file cannot tell you
|
|
33
|
+
//
|
|
34
|
+
// That the host honours what it emits. A schema-valid settings file the host ignores is
|
|
35
|
+
// indistinguishable, from in here, from one it enforces — see
|
|
36
|
+
// ../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md, which cost a milestone.
|
|
37
|
+
// The host behaviours above were measured against a running host on 2026-07-27 and are recorded in
|
|
38
|
+
// ../.portulan/compile/README.md with their probes. Re-measure on upgrade.
|
|
39
|
+
//
|
|
40
|
+
// Zero dependencies, no network, no install step — same constraints as ./doctor.mjs.
|
|
41
|
+
|
|
42
|
+
import fs from "node:fs";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
45
|
+
// Host plugin-cache discovery (#123). The record reader, the config directory and the version refusal
|
|
46
|
+
// all live there, once — see that file's pack-root section for why this half arrived separately.
|
|
47
|
+
import { AUTO, discoverPackRoots, namedWithAuto, resolutionRoots } from "./discover.mjs";
|
|
48
|
+
|
|
49
|
+
/** Raised when `compile` cannot run, or cannot compile honestly. Always exit 2, never 1. */
|
|
50
|
+
export class CompileError extends Error {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "CompileError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The GATE-POLICY spec versions this compiler understands — `gates.json`'s `portulan.spec`, which is a
|
|
58
|
+
// different train from the Workspace Definition's despite the overlapping numbers. Checked
|
|
59
|
+
// rather than ignored: `doctor` shipped for a day reading no version at all, so a manifest naming a
|
|
60
|
+
// spec that had never existed validated green. Same class of hole, closed at birth this time.
|
|
61
|
+
//
|
|
62
|
+
// 2.1 stays known: the `floor` key 2.2 adds is optional, so a 2.1 policy is still a policy this
|
|
63
|
+
// compiler reads correctly — it simply declares no floor, which is a legitimate shape and the one
|
|
64
|
+
// every workspace had yesterday.
|
|
65
|
+
//
|
|
66
|
+
// **These are GATE-POLICY versions, not Workspace Definition versions**, and the name is about to be
|
|
67
|
+
// misread by someone: the numbers overlap (`gates.json` is on 2.x and so is the Workspace Definition),
|
|
68
|
+
// the constant is spelled the same as ./index.mjs's and ./librarian.mjs's, and those two DO track the
|
|
69
|
+
// Workspace Definition. A fresh reviewer at milestone 6 session 1 sight-read this as a carrier left
|
|
70
|
+
// stale by the 2.5 → 2.6 bump and had to measure to clear it. It is correctly untouched by any
|
|
71
|
+
// Workspace Definition bump; renamed to say so, because the next reader may not measure.
|
|
72
|
+
const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2"]);
|
|
73
|
+
|
|
74
|
+
const TIERS = new Set(["auto", "propose", "gated", "prohibited"]);
|
|
75
|
+
|
|
76
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
77
|
+
|
|
78
|
+
// The `filePath` shape from ../spec/workspace.schema.json, carried here rather than imported because
|
|
79
|
+
// this file has no dependencies by design. Relative, no fragment/query/colon, not a directory.
|
|
80
|
+
const FILE_PATH = /^[^#?:/]([^#?:]*[^#?:/])?$/;
|
|
81
|
+
|
|
82
|
+
/** The tools that can write a path, and the tools that can read one. */
|
|
83
|
+
const WRITE_TOOLS = ["Edit", "Write", "NotebookEdit"];
|
|
84
|
+
const READ_TOOLS = ["Read"];
|
|
85
|
+
|
|
86
|
+
// ===========================================================================================
|
|
87
|
+
// 1. Parse: policy -> validated, normalised rules. NO backend opinion lives here.
|
|
88
|
+
// ===========================================================================================
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read a gate policy and hand back `{ rules, floor }` — validated and normalised, with every rule
|
|
92
|
+
* carrying its `kind` and `target` resolved, and nothing yet decided about enforcement.
|
|
93
|
+
*
|
|
94
|
+
* Anything it cannot understand refuses the WHOLE policy rather than dropping one rule — skipping
|
|
95
|
+
* and enforcing are indistinguishable from outside
|
|
96
|
+
* (../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md).
|
|
97
|
+
*
|
|
98
|
+
* **Why this stage holds no tier partition.** For one session it did: `auto` and `propose` were
|
|
99
|
+
* refused here, before any backend ran, with a sentence saying `propose` "is enforced by the
|
|
100
|
+
* platform floor". That sentence was right and its location was wrong — it names the *other*
|
|
101
|
+
* backend, and when that backend arrived it needed to compile exactly the rules this stage had
|
|
102
|
+
* already thrown away. A partition that is one backend's opinion must live in that backend, or the
|
|
103
|
+
* second one is structurally incapable of disagreeing.
|
|
104
|
+
*/
|
|
105
|
+
export function parse(policy) {
|
|
106
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
107
|
+
throw new CompileError("the gate policy is not a JSON object");
|
|
108
|
+
}
|
|
109
|
+
const spec = policy.portulan?.spec;
|
|
110
|
+
if (!KNOWN_GATE_POLICY_SPECS.has(String(spec))) {
|
|
111
|
+
throw new CompileError(
|
|
112
|
+
`gate policy declares gate-policy spec ${JSON.stringify(spec)}, which this compiler does not implement ` +
|
|
113
|
+
`(knows: ${[...KNOWN_GATE_POLICY_SPECS].join(", ")}). Refusing rather than compiling a policy it may misread.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(policy.rules) || policy.rules.length === 0) {
|
|
117
|
+
throw new CompileError("the gate policy declares no rules — refusing to emit an artifact that gates nothing");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const rules = [];
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
|
|
123
|
+
for (const rule of policy.rules) {
|
|
124
|
+
const id = rule?.id;
|
|
125
|
+
if (typeof id !== "string" || !SLUG.test(id)) {
|
|
126
|
+
throw new CompileError(`rule id ${JSON.stringify(id)} is not a slug — ids are referenced from prose and must be greppable`);
|
|
127
|
+
}
|
|
128
|
+
if (seen.has(id)) throw new CompileError(`duplicate rule id \`${id}\` — one id, one rule, or the gate map cannot cite either`);
|
|
129
|
+
seen.add(id);
|
|
130
|
+
|
|
131
|
+
if (!TIERS.has(rule.tier)) {
|
|
132
|
+
throw new CompileError(
|
|
133
|
+
`rule \`${id}\` declares tier ${JSON.stringify(rule.tier)}, which is not one of ${[...TIERS].join(" / ")}. ` +
|
|
134
|
+
`An unrecognised tier is not a rule to skip — skipping it would silently un-gate whatever it names.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (typeof rule.reason !== "string" || rule.reason.trim().length === 0) {
|
|
138
|
+
throw new CompileError(`rule \`${id}\` carries no reason — a gate with no sentence to show a human is not finished`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const action = rule.action;
|
|
142
|
+
if (!action || typeof action !== "object" || Array.isArray(action)) {
|
|
143
|
+
throw new CompileError(`rule \`${id}\` declares no action`);
|
|
144
|
+
}
|
|
145
|
+
const kinds = Object.keys(action);
|
|
146
|
+
if (kinds.length !== 1) {
|
|
147
|
+
throw new CompileError(
|
|
148
|
+
`rule \`${id}\` declares ${kinds.length} action kinds (${kinds.join(", ")}) — ambiguous is not the same as either, ` +
|
|
149
|
+
`and guessing which one was meant is how a gate ends up covering something other than what it says`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const [kind] = kinds;
|
|
153
|
+
if (!["shell", "write", "read", "none"].includes(kind)) {
|
|
154
|
+
throw new CompileError(`rule \`${id}\` declares action kind ${JSON.stringify(kind)}, which this compiler does not implement`);
|
|
155
|
+
}
|
|
156
|
+
if (typeof action[kind] !== "string" || action[kind].trim() === "") {
|
|
157
|
+
throw new CompileError(`rule \`${id}\`'s action \`${kind}\` has no value`);
|
|
158
|
+
}
|
|
159
|
+
// ONE REGEX WAS CARRYING TWO DIFFERENT JUSTIFICATIONS, and only one of them reaches every kind.
|
|
160
|
+
//
|
|
161
|
+
// For `shell`, `write` and `read` the value is interpolated into the host's permission DSL —
|
|
162
|
+
// `Bash(prefix:*)`, `Edit(./path)`. Characters structural THERE would emit a rule the host
|
|
163
|
+
// parses differently from what the policy meant, and an ambiguous gate is indistinguishable
|
|
164
|
+
// from an absent one. Refuse rather than escape: a workspace needing a gate on a command
|
|
165
|
+
// containing `(` or `:` needs this compiler extended deliberately, not quietly reinterpreted.
|
|
166
|
+
// Found by review on the pull request.
|
|
167
|
+
//
|
|
168
|
+
// **A `none` value is never interpolated into a permission pattern.** It is PROSE: the sentence
|
|
169
|
+
// the policy gives for why there is no surface, which the compiler reports verbatim rather than
|
|
170
|
+
// inventing its own — see the `kind === "none"` arm of the emitter, which pushes it as `why`,
|
|
171
|
+
// and the `refused` loop in the reporter, which prints it. (An earlier draft of this comment
|
|
172
|
+
// cited `describeRefusal`, a function that does not exist in this file — a name asserted without
|
|
173
|
+
// being looked up, in the same change whose subject is tracing what a value actually reaches.
|
|
174
|
+
// The nearest real thing is `floorRefusal`, which answers a different question.) So the DSL
|
|
175
|
+
// justification above does not reach it, and
|
|
176
|
+
// applying the DSL regex to it forbade **parentheses in an English sentence** — a gate refusing
|
|
177
|
+
// to compile over an aside. #205.
|
|
178
|
+
//
|
|
179
|
+
// What DOES still reach it is narrower and worth stating rather than folding back in: that
|
|
180
|
+
// sentence is printed into a LINE-BASED report — `refused ${id.padEnd(38)} ${why}` — so a
|
|
181
|
+
// newline or a tab in it breaks the column and splits one refusal across two lines. That is a
|
|
182
|
+
// different defect with a different fix, and it is the reason `none` keeps `\n\r\t` while
|
|
183
|
+
// losing `()` and `:`. Two justifications, two scopes, stated separately so the next reader
|
|
184
|
+
// cannot collapse them again.
|
|
185
|
+
const RESERVED = kind === "none" ? /[\n\r\t]/ : kind === "shell" ? /[()\n\r\t:]/ : /[()\n\r\t]/;
|
|
186
|
+
if (RESERVED.test(action[kind])) {
|
|
187
|
+
throw new CompileError(
|
|
188
|
+
kind === "none"
|
|
189
|
+
? `rule \`${id}\`'s none target ${JSON.stringify(action[kind])} contains a line break or tab. ` +
|
|
190
|
+
`That sentence is printed into a line-based refusal report, where it would split one ` +
|
|
191
|
+
`refusal across two lines and misalign every column after it.`
|
|
192
|
+
: `rule \`${id}\`'s ${kind} target ${JSON.stringify(action[kind])} contains a character that is ` +
|
|
193
|
+
`structural in the host's permission syntax. Emitting it would produce a rule the host reads ` +
|
|
194
|
+
`differently from what this policy says, which is worse than refusing to compile.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
// THE SAME OVER-REACH, NINE LINES BELOW THE SPLIT THAT FIXED IT. This message told every kind
|
|
198
|
+
// that the host "would not match" its value — and nothing about a `none` value is ever matched
|
|
199
|
+
// by the host, for the reason set out directly above. Surrounding whitespace on a `none` value
|
|
200
|
+
// is still worth refusing, but for a reason this comment got wrong once: the report's padding
|
|
201
|
+
// comes from `r.id.padEnd(38)`, not from `why`, so whitespace on the sentence cannot misalign a
|
|
202
|
+
// column. What it does do is shift the sentence out of line with every other refusal, and hide
|
|
203
|
+
// itself in the record. The message says that now — a refusal's stated reason must match its
|
|
204
|
+
// actual mechanism, which is this change's own subject, missed nine lines below its own fix. Found by Copilot on #256 round 1, in the change that had just split the
|
|
205
|
+
// reserved-character check on exactly this distinction: `0020` at its shortest range yet.
|
|
206
|
+
if (action[kind] !== action[kind].trim()) {
|
|
207
|
+
throw new CompileError(
|
|
208
|
+
kind === "none"
|
|
209
|
+
? `rule \`${id}\`'s none target has leading or trailing whitespace. That sentence is printed ` +
|
|
210
|
+
`verbatim into the refusal report, where leading whitespace shifts it out of line with every ` +
|
|
211
|
+
`other refusal and trailing whitespace is invisible in the record.`
|
|
212
|
+
: `rule \`${id}\`'s ${kind} target has leading or trailing whitespace, which the host would not match`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
// A path target that leaves the workspace is refused rather than normalised, for the reason
|
|
216
|
+
// directly above. Two spellings, one defect: `pattern()` and `matchesPath()` compare against a
|
|
217
|
+
// workspace-relative TAIL, so neither an absolute target nor one climbing out with `..` means
|
|
218
|
+
// at enforcement time what it says in the policy.
|
|
219
|
+
//
|
|
220
|
+
// "/etc/passwd" -> emits `Edit(./etc/passwd)` and matches any file whose path ends
|
|
221
|
+
// `/etc/passwd`, anywhere on the machine — broader than declared.
|
|
222
|
+
// "../secrets/" -> emits `Edit(./../secrets/**)`, which the host may resolve against the
|
|
223
|
+
// PARENT tree, while `matchesPath` can never match a `/../`-bearing tail
|
|
224
|
+
// against a resolved absolute path — narrower than declared, and the
|
|
225
|
+
// emitter and matcher disagree about which.
|
|
226
|
+
//
|
|
227
|
+
// Broader and narrower are both wrong, and the second is worse: it is a gate that reads as
|
|
228
|
+
// present and holds nothing. A workspace needing to gate a path outside its own tree needs
|
|
229
|
+
// this compiler extended deliberately. Shell targets are exempt: `/usr/bin/git` is a command
|
|
230
|
+
// spelling, not a path this compiler rewrites. Absolute found by review on #31; the `..`
|
|
231
|
+
// sibling by the fresh-context supervisor reviewing that fix, which is the same defect one
|
|
232
|
+
// spelling over.
|
|
233
|
+
if (kind === "write" || kind === "read") {
|
|
234
|
+
const escapes = action[kind].startsWith("/")
|
|
235
|
+
? "is an absolute path"
|
|
236
|
+
: action[kind].split("/").includes("..")
|
|
237
|
+
? "climbs out of the workspace with `..`"
|
|
238
|
+
: null;
|
|
239
|
+
if (escapes) {
|
|
240
|
+
throw new CompileError(
|
|
241
|
+
`rule \`${id}\`'s ${kind} target ${JSON.stringify(action[kind])} ${escapes}. The emitter and the ` +
|
|
242
|
+
`runtime matcher both compare against a workspace-relative tail, so the gate enforced would not ` +
|
|
243
|
+
`be the one declared. Refusing rather than silently rewriting it.`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
rules.push({ id, tier: rule.tier, kind, target: action[kind], reason: rule.reason, action });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { rules, floor: parseFloor(policy.floor) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The platform-floor declaration: the facts a floor backend would otherwise have to invent.
|
|
256
|
+
*
|
|
257
|
+
* Optional — a workspace that has declared no floor is a legitimate shape, and it is what every
|
|
258
|
+
* workspace was before this key existed. Present, it is validated whole: a half-declared floor is
|
|
259
|
+
* the one input from which a backend could emit something importable, valid, and weaker than the
|
|
260
|
+
* settings already in force.
|
|
261
|
+
*
|
|
262
|
+
* Four keys, and each is here because it varies per repository and the export would otherwise guess:
|
|
263
|
+
* which ref the floor protects, which status checks must be green, how many approving reviews are
|
|
264
|
+
* required, and whether review threads must be resolved. What is deliberately NOT declarable is
|
|
265
|
+
* `strict` — a pull request may not merge from behind its base
|
|
266
|
+
* (`../.portulan/proposals/0011-no-merge-from-behind-main.md`, applied live on 2026-07-27), so a
|
|
267
|
+
* policy able to declare `strict: false` would be a compiled artifact quietly undoing a ruling.
|
|
268
|
+
*/
|
|
269
|
+
function parseFloor(floor) {
|
|
270
|
+
if (floor === undefined || floor === null) return null;
|
|
271
|
+
if (typeof floor !== "object" || Array.isArray(floor)) {
|
|
272
|
+
throw new CompileError("`floor` is present but is not a JSON object");
|
|
273
|
+
}
|
|
274
|
+
const branch = floor.branch;
|
|
275
|
+
if (typeof branch !== "string" || !/^[A-Za-z0-9._\-/]+$/.test(branch) || branch.startsWith("/") || branch.endsWith("/")) {
|
|
276
|
+
throw new CompileError(
|
|
277
|
+
`\`floor.branch\` is ${JSON.stringify(branch)} — a floor must name the ref it protects, as an ordinary branch name. ` +
|
|
278
|
+
`Refusing rather than defaulting to \`main\`: a compiler that invents the ref it gates has stopped compiling policy.`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
// A branch NAME, never a full ref. The emitter prefixes `refs/heads/` unconditionally, so
|
|
282
|
+
// `refs/heads/main` here produced `refs/heads/refs/heads/main` — a ruleset GitHub accepts and
|
|
283
|
+
// that matches no ref in any repository. Importable, valid, enforcing nothing, in the one field
|
|
284
|
+
// that names what the floor protects. Found by review on the pull request. (`release/2026` is an
|
|
285
|
+
// ordinary branch name and stays legal; only a `refs/` prefix is refused.)
|
|
286
|
+
if (/^refs\//.test(branch)) {
|
|
287
|
+
throw new CompileError(
|
|
288
|
+
`\`floor.branch\` is ${JSON.stringify(branch)} — declare a branch NAME, not a full ref. This compiler emits ` +
|
|
289
|
+
`\`refs/heads/<branch>\`, so a \`refs/\` prefix here compiles to a ref no repository has, and a ruleset that ` +
|
|
290
|
+
`matches nothing is worse than one that refuses to build.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (!Array.isArray(floor.checks)) {
|
|
294
|
+
throw new CompileError("`floor.checks` must be an array — an absent one is indistinguishable from a floor requiring nothing");
|
|
295
|
+
}
|
|
296
|
+
const checks = floor.checks.map((c, i) => {
|
|
297
|
+
if (!c || typeof c !== "object" || typeof c.context !== "string" || c.context.trim() === "") {
|
|
298
|
+
throw new CompileError(`\`floor.checks[${i}]\` declares no \`context\` — a required check with no name cannot be required`);
|
|
299
|
+
}
|
|
300
|
+
// Refused rather than normalised, exactly as a rule target is a few lines up: a context
|
|
301
|
+
// stored with its surrounding whitespace is emitted with it, and GitHub then requires a
|
|
302
|
+
// check no job reports. Trimming it silently would hide a policy error in the file a human
|
|
303
|
+
// reviews. Found by review on the pull request.
|
|
304
|
+
if (c.context !== c.context.trim()) {
|
|
305
|
+
throw new CompileError(
|
|
306
|
+
`\`floor.checks[${i}].context\` is ${JSON.stringify(c.context)} — it has leading or trailing whitespace, which no ` +
|
|
307
|
+
`status check will report. Fix the policy rather than having this quietly trim it.`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
// The app pin is optional and its absence is a real weakening rather than a style choice: a
|
|
311
|
+
// context with no `integration_id` is satisfiable by ANY app reporting that name. Permitted,
|
|
312
|
+
// because a workspace may legitimately not know its app id, and reported by `doctor` rather
|
|
313
|
+
// than refused here. Recorded in ../.portulan/gate-map.md, which learned it the same way.
|
|
314
|
+
const pin = c.integration_id;
|
|
315
|
+
if (pin !== undefined && !Number.isInteger(pin)) {
|
|
316
|
+
throw new CompileError(`\`floor.checks[${i}].integration_id\` is not an integer — an app pin GitHub cannot read is not a pin`);
|
|
317
|
+
}
|
|
318
|
+
return pin === undefined ? { context: c.context } : { context: c.context, integration_id: pin };
|
|
319
|
+
});
|
|
320
|
+
if (!Number.isInteger(floor.reviews) || floor.reviews < 0) {
|
|
321
|
+
throw new CompileError("`floor.reviews` must be a non-negative integer — the required approving review count is policy, not a default");
|
|
322
|
+
}
|
|
323
|
+
if (typeof floor.resolve_conversations !== "boolean") {
|
|
324
|
+
throw new CompileError("`floor.resolve_conversations` must be a boolean — omitting it would export a floor weaker than the one in force");
|
|
325
|
+
}
|
|
326
|
+
return { branch, checks, reviews: floor.reviews, resolve_conversations: floor.resolve_conversations };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ===========================================================================================
|
|
330
|
+
// 2. What an action MEANS — shared by the compiler and the runtime gate
|
|
331
|
+
// ===========================================================================================
|
|
332
|
+
//
|
|
333
|
+
// The emitter and the hook runner must agree about what `{"shell": "git push"}` covers, and the
|
|
334
|
+
// only way to guarantee that is one definition. Two implementations of one matcher is the drift
|
|
335
|
+
// this repository keeps finding, and a *matcher* that drifts does not look wrong — it looks like a
|
|
336
|
+
// gate that quietly stopped covering something.
|
|
337
|
+
//
|
|
338
|
+
// `./gate.mjs` imports these. It is the only thing outside this file that may.
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The spellings of a shell command a gate will match against: the command itself, and the same
|
|
342
|
+
* command with one `sh -c` / `bash -c` / `zsh -c` wrapper peeled off.
|
|
343
|
+
*
|
|
344
|
+
* The permission rule this compiles to sees only the first — `Bash(git push:*)` is a literal
|
|
345
|
+
* prefix match, so `bash -c "git push …"` is invisible to it. Measured, not assumed. The hook
|
|
346
|
+
* runner sees both, which is the one thing it does that the permission layer cannot, and therefore
|
|
347
|
+
* the reason it is emitted at all.
|
|
348
|
+
*
|
|
349
|
+
* **One level, and no more.** This closes the spelling reached for by accident or convenience, not
|
|
350
|
+
* one constructed on purpose: deeper nesting, a heredoc, an interpolated variable, or a command
|
|
351
|
+
* assembled at runtime all still escape. No amount of parsing here would close that, and an
|
|
352
|
+
* ambitious parser would buy false confidence with false reds. What must not happen regardless of
|
|
353
|
+
* spelling belongs on the platform floor — see ../core/operating/autonomy.md, which calls the floor
|
|
354
|
+
* the gate that holds when this layer fails.
|
|
355
|
+
*/
|
|
356
|
+
export function spellings(raw) {
|
|
357
|
+
const command = String(raw ?? "").trim();
|
|
358
|
+
const out = [command];
|
|
359
|
+
const wrapper = /^(?:\/usr\/bin\/env\s+)?(?:ba|z|da)?sh\s+-[a-zA-Z]*c\s+(.*)$/s.exec(command);
|
|
360
|
+
if (wrapper) {
|
|
361
|
+
let inner = wrapper[1].trim();
|
|
362
|
+
// The same `$'…'` / `$"…"` forms `shellWords` strips, at the other site that reads a quote.
|
|
363
|
+
// Two sites, because a wrapped command is unwrapped HERE and a word is tokenised THERE, and
|
|
364
|
+
// fixing one left `bash -c $'git push --force origin main'` still stepping aside while
|
|
365
|
+
// `cp /tmp/x $'docs/vision.md'` had started denying — measured, and the reason this is not
|
|
366
|
+
// one edit. Found by Copilot on #60.
|
|
367
|
+
if (inner[0] === "$" && (inner[1] === "'" || inner[1] === '"')) inner = inner.slice(1);
|
|
368
|
+
const quote = inner[0];
|
|
369
|
+
if ((quote === '"' || quote === "'") && inner.endsWith(quote) && inner.length > 1) {
|
|
370
|
+
inner = inner.slice(1, -1);
|
|
371
|
+
}
|
|
372
|
+
if (inner.trim()) out.push(inner.trim());
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// The shell spellings of a write, recognised by table. Below, `shellWrites` asks one question of a
|
|
378
|
+
// command — *does it name the protected path in a position where that path is being written* — and
|
|
379
|
+
// these are the two ways it can answer yes without parsing a shell.
|
|
380
|
+
//
|
|
381
|
+
// A table rather than a parser, for the reason `REF_RULES` further down is a table: recognition by
|
|
382
|
+
// exact spelling is a limit a reader can measure, and a matcher clever enough to generalise is clever
|
|
383
|
+
// enough to be wrong quietly.
|
|
384
|
+
|
|
385
|
+
// `git` is deliberately absent, though `git checkout -- <path>` and `git restore` both overwrite a file.
|
|
386
|
+
// The head of those commands is `git`, so admitting it would gate `git diff <path>` and `git log` in the
|
|
387
|
+
// same stroke — and a gate on READING a path is a rule no policy here declares. It is the likeliest
|
|
388
|
+
// uncovered writer in this repository, which is why ../.portulan/gate-map.md names it rather than leaving
|
|
389
|
+
// it inside "any writer outside the table".
|
|
390
|
+
/** Commands whose job is to write, replace or remove a file they NAME on their own command line. */
|
|
391
|
+
export const FILE_WRITERS = new Set(["cp", "mv", "ln", "rm", "tee", "dd", "install", "truncate", "shred", "patch"]);
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Editors that READ their arguments by default and write them only under an in-place flag.
|
|
395
|
+
*
|
|
396
|
+
* Separated from the table above rather than folded into it, because folding them in would gate
|
|
397
|
+
* `sed -n '1,5p' docs/vision.md` — which is a *read*, and which this policy declares Auto. A matcher
|
|
398
|
+
* that contradicts a declared tier is worse than one that admits a gap.
|
|
399
|
+
*/
|
|
400
|
+
export const IN_PLACE_EDITORS = new Set(["sed", "gsed", "perl", "ruby"]);
|
|
401
|
+
|
|
402
|
+
/** Words that stand in front of the command that actually runs. Recognised, not parsed: `sudo cp …` is seen, `sudo -u someone cp …` is not. */
|
|
403
|
+
const COMMAND_PREFIXES = new Set(["sudo", "env", "command", "builtin", "exec", "nohup", "nice", "time"]);
|
|
404
|
+
|
|
405
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
406
|
+
|
|
407
|
+
// A NEWLINE is a command separator and belongs in this class, which it did not for one review round.
|
|
408
|
+
// The Bash tool receives multi-line scripts constantly, so
|
|
409
|
+
//
|
|
410
|
+
// git status
|
|
411
|
+
// cp /tmp/x docs/vision.md
|
|
412
|
+
//
|
|
413
|
+
// folded into ONE segment whose head was `git` — not a writer — and the whole table half fell through
|
|
414
|
+
// to false. The most ordinary spelling there is, and it defeated the gate while the redirection half
|
|
415
|
+
// (which reads no head) kept working, so the coverage looked alive. Found by the fresh-context
|
|
416
|
+
// supervisor at the pre-merge checkpoint.
|
|
417
|
+
const OPERATOR = /[|&;<>()\n\r]/;
|
|
418
|
+
|
|
419
|
+
// Words that lead a segment without being the command in it. `{ cp … ; }`, `if …; then cp …`, and
|
|
420
|
+
// `for …; do cp …` all put one of these where the head goes, which hid a writer behind it. Not a
|
|
421
|
+
// grammar — a list of leaders, and `find -exec cp` / `xargs cp` stay uncovered because parsing THEIR
|
|
422
|
+
// flags to find the real command is the ambitious parser this file keeps refusing to become.
|
|
423
|
+
const SEGMENT_LEADERS = new Set(["{", "}", "!", "then", "else", "elif", "do", "done", "fi", "esac"]);
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Drop heredoc BODIES, keeping the line that opens them.
|
|
427
|
+
*
|
|
428
|
+
* A heredoc body is data being written, not commands being run, so reading it as commands is simply
|
|
429
|
+
* wrong — and it became wrong the moment a newline started separating commands, because that is what
|
|
430
|
+
* turned each body line into its own segment. Measured immediately and expensively: this change's own
|
|
431
|
+
* commit, whose message quoted `cp /tmp/x docs/vision.md` as the escape being fixed, was refused by the
|
|
432
|
+
* gate it was adding. A matcher that blocks you from describing an attack is not being cautious.
|
|
433
|
+
*
|
|
434
|
+
* The opening line stays, so `cat > docs/vision.md <<'EOF'` still gates — the redirection is on it.
|
|
435
|
+
* What is dropped is the text between the delimiter and its terminator, which no shell executes.
|
|
436
|
+
*/
|
|
437
|
+
function stripHeredocs(command) {
|
|
438
|
+
const out = [];
|
|
439
|
+
let delimiter = null;
|
|
440
|
+
let held = []; // lines after an opener whose terminator has not been seen yet
|
|
441
|
+
for (const line of command.split("\n")) {
|
|
442
|
+
if (delimiter !== null) {
|
|
443
|
+
if (line.trim() === delimiter) {
|
|
444
|
+
// A real heredoc. Its body is data, not command text, so both the body and the
|
|
445
|
+
// terminator line go.
|
|
446
|
+
delimiter = null;
|
|
447
|
+
held = [];
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
held.push(line);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
out.push(line);
|
|
454
|
+
const opened = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line);
|
|
455
|
+
if (opened) delimiter = opened[2];
|
|
456
|
+
}
|
|
457
|
+
// **An opener with no terminator was not an opener.** The regex reads a raw line, so `<<EOF`
|
|
458
|
+
// inside a quoted string or after a `#` sets `delimiter` on text that opens nothing — and the
|
|
459
|
+
// loop above would then swallow every remaining line looking for a word that never comes.
|
|
460
|
+
//
|
|
461
|
+
// That is a FAIL-OPEN manufactured by a defensive step, which is worse than the parsing gap it
|
|
462
|
+
// was added to close: a gated command on any later line becomes invisible to every matcher
|
|
463
|
+
// downstream. Measured on the runner before this branch existed —
|
|
464
|
+
// `echo "not a heredoc <<EOF"\ngit push --force origin main` stepped aside where the bare
|
|
465
|
+
// command answers `ask`, and `# <<EOF` did the same. Found by Copilot review on #60.
|
|
466
|
+
//
|
|
467
|
+
// Giving the lines back is the fail-CLOSED direction and costs only a false red: a genuine
|
|
468
|
+
// heredoc always has its terminator, so this branch cannot reach one. What it does not close is
|
|
469
|
+
// a mis-detected opener whose delimiter happens to appear later anyway; that is stated in the
|
|
470
|
+
// gate map rather than chased with a quote-aware parser this file refuses to grow.
|
|
471
|
+
if (delimiter !== null) out.push(...held);
|
|
472
|
+
return out.join("\n");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** A shell command split into words, with unquoted operators kept as words of their own. */
|
|
476
|
+
function shellWords(command) {
|
|
477
|
+
const words = [];
|
|
478
|
+
let text = "";
|
|
479
|
+
let open = false; // a word has begun — so an empty `""` argument survives as a word
|
|
480
|
+
const flush = () => {
|
|
481
|
+
if (open) words.push({ text, op: false });
|
|
482
|
+
text = "";
|
|
483
|
+
open = false;
|
|
484
|
+
};
|
|
485
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
486
|
+
const c = command[i];
|
|
487
|
+
// `$'…'` (ANSI-C quoting) and `$"…"` (locale translation) are quoting forms whose `$` is not
|
|
488
|
+
// part of the word. Without this the `$` glued onto the front — `$'docs/vision.md'` tokenised
|
|
489
|
+
// as `$docs/vision.md` — and no target matched. Measured on the runner before this branch:
|
|
490
|
+
// `bash -c $'git push --force origin main'` stepped aside where the plain wrapper answers
|
|
491
|
+
// `ask`, and `cp /tmp/x $'docs/vision.md'` stepped aside where the plain target denies. So
|
|
492
|
+
// this was a live bypass of both a Gated shell target and the constitution's write gate,
|
|
493
|
+
// sitting inside the claim one line down that quoting is honoured. Found by Copilot on #60.
|
|
494
|
+
//
|
|
495
|
+
// **Two forms and no more, because this grammar is closed.** `$(…)` is command substitution
|
|
496
|
+
// and deliberately stays out: its content is a command to run, not a word to read, and
|
|
497
|
+
// reading it as a word is precisely how a matcher becomes clever and wrong quietly. That one
|
|
498
|
+
// is hole 1's "command assembled at runtime", where it belongs.
|
|
499
|
+
if (c === "$" && (command[i + 1] === "'" || command[i + 1] === '"')) continue;
|
|
500
|
+
if (c === "'" || c === '"') {
|
|
501
|
+
// Quoted runs are taken whole, and the quotes are stripped: `cp /tmp/x 'docs/vision.md'`
|
|
502
|
+
// must reach the gate, which is what goes red if this branch is removed. (An earlier
|
|
503
|
+
// draft of this comment claimed it was what stops `echo "x > docs/vision.md"` reading as
|
|
504
|
+
// a redirection. Measured at the pre-merge checkpoint: false — without this branch that
|
|
505
|
+
// target tokenises as `docs/vision.md"`, trailing quote and all, and fails to match
|
|
506
|
+
// anyway. Right branch, wrong reason, which `a-stated-enforcer-must-be-the-real-one`
|
|
507
|
+
// counts as the same defect one size down.)
|
|
508
|
+
// Scanned rather than `indexOf`, for the escaped-quote reason above: inside `"…"` a
|
|
509
|
+
// `\"` is a literal quote and must not end the run. The escape is resolved into the word,
|
|
510
|
+
// so `cp /tmp/x "docs/\"v\".md"` compares as the path a shell would pass.
|
|
511
|
+
//
|
|
512
|
+
// `$'…'` reaches here with its `$` already dropped and is read as a single-quoted run, so
|
|
513
|
+
// ANSI-C escape sequences (`\n`, `\t`, `\x41`) are NOT decoded — a stated limit, and the
|
|
514
|
+
// ambitious parser this file refuses to become. A path spelled with them escapes; that is
|
|
515
|
+
// hole 1's "interpolated path" neighbourhood rather than a new one.
|
|
516
|
+
let j = i + 1;
|
|
517
|
+
let run = "";
|
|
518
|
+
while (j < command.length) {
|
|
519
|
+
if (c === '"' && command[j] === "\\" && j + 1 < command.length) {
|
|
520
|
+
run += command[j + 1];
|
|
521
|
+
j += 2;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (command[j] === c) break;
|
|
525
|
+
run += command[j];
|
|
526
|
+
j += 1;
|
|
527
|
+
}
|
|
528
|
+
text += run;
|
|
529
|
+
open = true;
|
|
530
|
+
i = j >= command.length ? command.length : j;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (c === "\\" && i + 1 < command.length) {
|
|
534
|
+
// A backslash-newline is a line continuation: both characters vanish, and the word
|
|
535
|
+
// continues on the next line. Appending the newline instead would have glued it into the
|
|
536
|
+
// middle of a path, which no target matches.
|
|
537
|
+
// `\r\n` is consumed as a PAIR. Skipping one character left the `\n` behind, and a
|
|
538
|
+
// newline is an operator here, so a CRLF continuation flushed the word instead of
|
|
539
|
+
// continuing it: `cp /tmp/x \\<CRLF>docs/vision.md` stepped aside where the LF spelling
|
|
540
|
+
// answers `deny` — the constitution, reachable by editing the file on Windows. Measured
|
|
541
|
+
// both ways, 2026-07-28. Found by Copilot review on #60.
|
|
542
|
+
if (command[i + 1] === "\r" && command[i + 2] === "\n") {
|
|
543
|
+
i += 2;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (command[i + 1] === "\n" || command[i + 1] === "\r") {
|
|
547
|
+
i += 1;
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
text += command[i + 1];
|
|
551
|
+
open = true;
|
|
552
|
+
i += 1;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
// Operators are tested BEFORE whitespace, because a newline is both and the separator reading
|
|
556
|
+
// is the load-bearing one.
|
|
557
|
+
if (OPERATOR.test(c)) {
|
|
558
|
+
flush();
|
|
559
|
+
let run = c;
|
|
560
|
+
while (i + 1 < command.length && OPERATOR.test(command[i + 1])) {
|
|
561
|
+
run += command[i + 1];
|
|
562
|
+
i += 1;
|
|
563
|
+
}
|
|
564
|
+
words.push({ text: run, op: true });
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (/\s/.test(c)) {
|
|
568
|
+
flush();
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
text += c;
|
|
572
|
+
open = true;
|
|
573
|
+
}
|
|
574
|
+
flush();
|
|
575
|
+
return words;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Those words folded into `{ head, args, redirects }` per command in a pipeline or list. */
|
|
579
|
+
function shellSegments(command) {
|
|
580
|
+
const segments = [];
|
|
581
|
+
let current = { head: null, args: [], redirects: [] };
|
|
582
|
+
let pending = null;
|
|
583
|
+
const close = () => {
|
|
584
|
+
if (current.head !== null || current.args.length || current.redirects.length) segments.push(current);
|
|
585
|
+
current = { head: null, args: [], redirects: [] };
|
|
586
|
+
};
|
|
587
|
+
for (const word of shellWords(command)) {
|
|
588
|
+
if (word.op) {
|
|
589
|
+
// `>` `>>` `2>` `&>` — whatever follows is written, whatever the command is. `<` and `<<`
|
|
590
|
+
// introduce something READ, so the word after them is SKIPPED rather than ending the
|
|
591
|
+
// segment: `tee < /tmp/in docs/vision.md` keeps `tee` as the head and its real argument
|
|
592
|
+
// as an argument, which is what goes red if this branch is removed. (An earlier draft
|
|
593
|
+
// justified it with `patch f < d.diff` instead. Measured: that one is safe either way,
|
|
594
|
+
// because without this branch the `<` merely closes the segment and `d.diff` becomes a
|
|
595
|
+
// head rather than an argument. A branch defended by a hazard that cannot happen is a
|
|
596
|
+
// branch nobody can review.)
|
|
597
|
+
if (word.text.includes(">")) pending = "written";
|
|
598
|
+
else if (word.text.includes("<")) pending = "read";
|
|
599
|
+
else {
|
|
600
|
+
pending = null;
|
|
601
|
+
close();
|
|
602
|
+
}
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (pending) {
|
|
606
|
+
if (pending === "written") current.redirects.push(word.text);
|
|
607
|
+
pending = null;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (current.head === null) {
|
|
611
|
+
const bare = word.text.split("/").pop();
|
|
612
|
+
if (ASSIGNMENT.test(word.text) || COMMAND_PREFIXES.has(bare) || SEGMENT_LEADERS.has(bare)) continue;
|
|
613
|
+
current.head = bare;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
current.args.push(word.text);
|
|
617
|
+
}
|
|
618
|
+
close();
|
|
619
|
+
return segments;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Does this segment write the files it names? */
|
|
623
|
+
function writesWhatItNames({ head, args }) {
|
|
624
|
+
if (head === null) return false;
|
|
625
|
+
if (FILE_WRITERS.has(head)) return true;
|
|
626
|
+
// `-i`, `-i.bak`, `-pi`, `--in-place`, `--in-place=.bak`. A long option other than `--in-place`
|
|
627
|
+
// cannot match, because the single-dash form requires a letter run and `-` is not one.
|
|
628
|
+
return IN_PLACE_EDITORS.has(head) && args.some((a) => /^--in-place(=|$)/.test(a) || /^-[a-zA-Z]*i/.test(a));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* A path as typed on a command line, reduced to the spelling the tail comparison can read.
|
|
633
|
+
*
|
|
634
|
+
* `matchesPath` compares tails literally, which is right for the host's own clean absolute paths and
|
|
635
|
+
* wrong for anything a person types: `docs/./vision.md` and `docs//vision.md` are the constitution
|
|
636
|
+
* and neither ends with `/docs/vision.md`. Both were live escapes for one review round. `..` is
|
|
637
|
+
* resolved for the same reason and in the same place — `foo/../docs/vision.md` is the file too.
|
|
638
|
+
*/
|
|
639
|
+
function normalisePath(p) {
|
|
640
|
+
const absolute = p.startsWith("/");
|
|
641
|
+
const out = [];
|
|
642
|
+
for (const part of p.split("/")) {
|
|
643
|
+
if (part === "" || part === ".") continue;
|
|
644
|
+
if (part === ".." && out.length && out[out.length - 1] !== "..") out.pop();
|
|
645
|
+
else out.push(part);
|
|
646
|
+
}
|
|
647
|
+
return (absolute ? "/" : "") + out.join("/");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* The directories a target lives under, as exact paths: `a/b/c.md` -> `a`, `a/b`.
|
|
652
|
+
*
|
|
653
|
+
* No trailing slash, and that is the whole correctness of it. Spelled `a/` these would be SUBTREE
|
|
654
|
+
* patterns, and `matchesPath` would then report every file under the directory as a hit — so
|
|
655
|
+
* `cp foo docs/plan.md` matched a rule protecting `docs/vision.md`, which is a false red on an
|
|
656
|
+
* ordinary edit to an unrelated file. Caught by this change's own probe before it left the worktree.
|
|
657
|
+
*/
|
|
658
|
+
function ancestors(target) {
|
|
659
|
+
const parts = normalisePath(target.replace(/\/+$/, "")).split("/").filter(Boolean);
|
|
660
|
+
return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join("/"));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Does a word name the target — quoted, `./`-prefixed, absolute, messy, or after an `of=`-style `=`?
|
|
665
|
+
*
|
|
666
|
+
* With `orAncestor`, a word naming a DIRECTORY the target lives in counts too. That is what makes
|
|
667
|
+
* `rm -rf docs` and `mv docs docs.bak` reach a rule protecting `docs/vision.md`: destroying the
|
|
668
|
+
* container destroys the file, and a gate that reads `rm -rf docs/` and misses `rm -rf docs` is
|
|
669
|
+
* decided by a trailing slash. Ancestors apply to a writer's arguments only, never to a redirection
|
|
670
|
+
* target — `> docs` writes a file called `docs`, it does not remove a directory.
|
|
671
|
+
*/
|
|
672
|
+
function namesTarget(word, target, orAncestor = false) {
|
|
673
|
+
const eq = word.indexOf("=");
|
|
674
|
+
const candidates = eq > 0 ? [word, word.slice(eq + 1)] : [word];
|
|
675
|
+
return candidates.some((c) => {
|
|
676
|
+
const clean = normalisePath(c);
|
|
677
|
+
if (clean === "") return false;
|
|
678
|
+
// A relative word is given the leading separator the tail comparison needs, rather than a
|
|
679
|
+
// second matcher being written for it.
|
|
680
|
+
const rooted = clean.startsWith("/") ? clean : `/${clean}`;
|
|
681
|
+
// The target itself. A subtree target wants the candidate to look like a directory, which is
|
|
682
|
+
// what lets `rm -rf .portulan` reach a rule written `.portulan/`.
|
|
683
|
+
if (matchesPath(target.endsWith("/") ? `${rooted}/` : rooted, target)) return true;
|
|
684
|
+
// A directory the target lives in, named EXACTLY — `rm -rf docs`, never `cp x docs/plan.md`.
|
|
685
|
+
return orAncestor && ancestors(target).some((a) => matchesPath(rooted, a));
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* The commands in a shell line, as SOURCE text, split on unquoted separators.
|
|
691
|
+
*
|
|
692
|
+
* The shell matcher below prefix-matches a command string, which meant a gate held only when its
|
|
693
|
+
* command came FIRST: `ls && git push --force origin main` reached no gate, and neither did
|
|
694
|
+
* `git status; gh pr merge 60` or `cd . && gh repo delete foo`. Every Gated outward action in
|
|
695
|
+
* ../.portulan/gates.json was defeated by putting anything in front of it.
|
|
696
|
+
*
|
|
697
|
+
* That is the same defect as the `write:` one this change began with — a matcher reading only the
|
|
698
|
+
* head of a command string — one action kind over, so it is fixed in the same stroke rather than
|
|
699
|
+
* left as a sibling nobody comes back for. Found by the fresh-context supervisor at the pre-merge
|
|
700
|
+
* checkpoint, which is exactly the class it was asked to hunt.
|
|
701
|
+
*
|
|
702
|
+
* Quoting is respected, so `echo "git push --force"` still splits into one segment that no gate
|
|
703
|
+
* matches; and each segment keeps the whitespace boundary the prefix match relies on, so
|
|
704
|
+
* `--force-with-lease` stays Auto.
|
|
705
|
+
*/
|
|
706
|
+
function commandSegments(raw) {
|
|
707
|
+
// `String(raw ?? "")`, for the reason `spellings` does the same: `matchesRule` promises never to
|
|
708
|
+
// throw, and a Bash payload carrying no `command` — a malformed host message, a renamed field, a
|
|
709
|
+
// test — would otherwise reach `stripHeredocs` and die on `.split`.
|
|
710
|
+
//
|
|
711
|
+
// What that costs is not an exception some caller reports. ./gate.mjs catches
|
|
712
|
+
// and steps aside, so the throw would SILENTLY remove the shell-write half of the constitution
|
|
713
|
+
// gate — hole 3, the half with no permission rule beneath it. Introduced earlier on this branch
|
|
714
|
+
// by the segment-composition fix, which began passing the raw payload here in place of a spelling
|
|
715
|
+
// that had already been stringified. Found by Copilot review on #60.
|
|
716
|
+
const command = stripHeredocs(String(raw ?? ""));
|
|
717
|
+
const out = [];
|
|
718
|
+
let start = 0;
|
|
719
|
+
let quote = null;
|
|
720
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
721
|
+
const c = command[i];
|
|
722
|
+
if (quote) {
|
|
723
|
+
// Inside `"…"` a backslash escapes the next character, so `\"` is a literal quote and does
|
|
724
|
+
// NOT close the run. Without this, `echo "x\""; git push --force …` closed early, the real
|
|
725
|
+
// closing quote opened a new run, and the `;` was swallowed inside it — no split, no
|
|
726
|
+
// segment, no gate. Measured stepping aside where the unescaped spelling answers `ask`,
|
|
727
|
+
// and the same shape reached `cp /tmp/x docs/vision.md`, so it was a false green on the
|
|
728
|
+
// constitution too. Found by Copilot review on #60.
|
|
729
|
+
//
|
|
730
|
+
// Single quotes are deliberately NOT included: POSIX gives `'…'` no escapes at all, so a
|
|
731
|
+
// backslash there is a literal backslash and honouring it would invent a hole.
|
|
732
|
+
if (quote === '"' && c === "\\" && i + 1 < command.length) {
|
|
733
|
+
i += 1;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (c === quote) quote = null;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (c === "'" || c === '"') {
|
|
740
|
+
quote = c;
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (c === "\\") {
|
|
744
|
+
// The same CRLF pair, in the other reader. No spelling was measured behaving differently
|
|
745
|
+
// here — a continuation split into two segments still leaves a gated command at the head
|
|
746
|
+
// of one — so this is fixed for the reason the session kept re-learning rather than for a
|
|
747
|
+
// failing case: one carrier corrected and its sibling left is how the last three defects
|
|
748
|
+
// on this branch happened.
|
|
749
|
+
i += command[i + 1] === "\r" && command[i + 2] === "\n" ? 2 : 1;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
// `#` is NOT treated as starting a comment, and that is a decision rather than an oversight.
|
|
753
|
+
// `echo ok #; git push --force origin main` therefore segments as though the comment were
|
|
754
|
+
// code and answers `ask`, which a real shell would not — a false RED, measured 2026-07-28.
|
|
755
|
+
//
|
|
756
|
+
// Taking it would mean deciding where a comment begins, and the failure mode of getting that
|
|
757
|
+
// wrong runs the other way. A `#` opens a comment only at a word boundary and only outside
|
|
758
|
+
// quotes; a reader that skipped from the first `#` would swallow the rest of
|
|
759
|
+
// `echo "a#b"; git push --force origin main` — a real gated command, measured answering `ask`
|
|
760
|
+
// today — and turn a false red into a false GREEN. The docblock on `shellWrites` states the
|
|
761
|
+
// exchange rate: a false red costs one prompt on a rare spelling, a false green costs the
|
|
762
|
+
// laundering the rule exists to prevent.
|
|
763
|
+
//
|
|
764
|
+
// Both spellings are asserted in the suite so this stays a choice rather than drift.
|
|
765
|
+
// Reported by Copilot review on #60 and declined on those grounds.
|
|
766
|
+
if (";|&()\n\r".includes(c)) {
|
|
767
|
+
out.push(command.slice(start, i));
|
|
768
|
+
start = i + 1;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
out.push(command.slice(start));
|
|
772
|
+
return out.map((s) => s.trim()).filter(Boolean);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Does this shell command write a path a `write:` rule protects?
|
|
777
|
+
*
|
|
778
|
+
* The permission layer cannot ask this. `Bash(prefix:*)` matches a literal command PREFIX and the
|
|
779
|
+
* path sits at an arbitrary position in the command, so there is no pattern in that DSL which means
|
|
780
|
+
* "any command writing this file" — which makes this the hook's coverage alone, exactly like the
|
|
781
|
+
* wrapper spelling above, and it fails open on the same terms.
|
|
782
|
+
*
|
|
783
|
+
* **Stated at its real size, because the boundary is the point.** Two recognitions, both by table:
|
|
784
|
+
* a `>`/`>>` redirection into the path, and a file-writing command that names it — or names a
|
|
785
|
+
* directory the path lives in, since removing the container removes the file.
|
|
786
|
+
*
|
|
787
|
+
* A heredoc is NOT on that list, and the omission is worth stating rather than leaving to be read as
|
|
788
|
+
* one: the body is stripped and the command line survives, so `cat > docs/vision.md <<EOF` is reached
|
|
789
|
+
* and gated — asserted in the suite. What escapes is an interpolated TARGET, heredoc or not, which is
|
|
790
|
+
* the first item below. The emitted note said "a heredoc" flatly until 2026-07-28 and overstated the
|
|
791
|
+
* hole; a gate map that overstates a hole is as wrong as one that hides it, and so is a compiler note.
|
|
792
|
+
*
|
|
793
|
+
* What that leaves open, listed because a hole list that is wrong is worse than none: an
|
|
794
|
+
* interpolated path (`> $VISION`), a command assembled at runtime, a language runtime writing the
|
|
795
|
+
* file itself (`python3 -c`), a writer absent from the table (`ex`, and `git checkout` deliberately
|
|
796
|
+
* — see above), a program that INVOKES a writer (`find -exec cp`, `xargs cp`) because parsing their
|
|
797
|
+
* flags to find the real command is the parser this file refuses to become, and two shell wrappers.
|
|
798
|
+
* Quoting is respected only to one level of nesting, so a write-shaped string inside a `node -e`
|
|
799
|
+
* script can produce a false RED — measured on this repository's own tooling.
|
|
800
|
+
*
|
|
801
|
+
* The first version of this shipped a four-item hole list that was missing five holes, including
|
|
802
|
+
* the plainest spelling there is: a newline. A fresh-context supervisor found them by trying to
|
|
803
|
+
* defeat the matcher rather than by reading it, which is the only way that list gets checked.
|
|
804
|
+
*
|
|
805
|
+
* This closes the spelling reached for by accident or convenience; it does not close one
|
|
806
|
+
* constructed on purpose, and no matcher here could. What must not happen regardless of spelling
|
|
807
|
+
* belongs on the platform floor — ../core/operating/autonomy.md.
|
|
808
|
+
*
|
|
809
|
+
* **Every named argument of a writer counts, not only its destination.** `cp docs/vision.md /tmp/x`
|
|
810
|
+
* reads the protected path rather than writing it, and this matches it anyway. Deliberate: argument
|
|
811
|
+
* grammars differ per command (`dd of=`, `tee f1 f2`, `install -t dir src`), so "the last word is
|
|
812
|
+
* the destination" is true of a subset only, and being wrong about it is a false GREEN on a rule
|
|
813
|
+
* whose whole reason is that the file must not change. A false red here costs one prompt on a rare
|
|
814
|
+
* operation; a false green costs the laundering the rule exists to prevent.
|
|
815
|
+
*/
|
|
816
|
+
export function shellWrites(command, target) {
|
|
817
|
+
for (const segment of shellSegments(stripHeredocs(String(command ?? "")))) {
|
|
818
|
+
if (segment.redirects.some((word) => namesTarget(word, target))) return true;
|
|
819
|
+
if (writesWhatItNames(segment) && segment.args.some((word) => namesTarget(word, target, true))) return true;
|
|
820
|
+
}
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Does a path handed over by the host fall under a policy target? */
|
|
825
|
+
export function matchesPath(candidate, target) {
|
|
826
|
+
if (typeof candidate !== "string" || candidate === "") return false;
|
|
827
|
+
const clean = String(target ?? "").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
828
|
+
if (clean === "" || clean === "/") return false;
|
|
829
|
+
// Compared against the tail rather than resolved against a root: the host hands over an
|
|
830
|
+
// absolute path, the policy names a repository-relative one, and the hook has no reliable
|
|
831
|
+
// repository root at hook time — its cwd is the session's, which need not be the repo.
|
|
832
|
+
const normalised = candidate.replace(/\\/g, "/");
|
|
833
|
+
return clean.endsWith("/") ? normalised.includes(`/${clean}`) : normalised.endsWith(`/${clean}`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Does an attempted tool call fall under this rule? Returns true/false; never throws. */
|
|
837
|
+
export function matchesRule(rule, tool, input = {}) {
|
|
838
|
+
const action = rule?.action ?? {};
|
|
839
|
+
if (typeof action.shell === "string" && tool === "Bash") {
|
|
840
|
+
// A shell target ending in `/` is a PATH PREFIX covering the subtree — the meaning `pattern()`
|
|
841
|
+
// already gives a trailing slash for write and read, and the meaning the emitted
|
|
842
|
+
// `Bash(target:*)` rule already has on the host, which prefix-matches the command string.
|
|
843
|
+
// Without this branch the two halves this section exists to keep identical disagreed: for
|
|
844
|
+
// `"./.portulan/verify/"` the emitted rule covers `./.portulan/verify/docs.sh` and this
|
|
845
|
+
// matcher did not, because a path prefix is never followed by a space.
|
|
846
|
+
//
|
|
847
|
+
// **Stated at its real size.** The one target of this shape in ../.portulan/gates.json is
|
|
848
|
+
// `run-a-verify-recipe`, which is `auto` — refused from compilation, and skipped by
|
|
849
|
+
// ./gate.mjs, which reads only `gated` and `prohibited`. So nothing was
|
|
850
|
+
// mis-enforced today; what existed was a divergence between two definitions this file
|
|
851
|
+
// promises are one, waiting for the first gated rule written in the path form. Fixed on that
|
|
852
|
+
// basis rather than on an incident. An ordinary command prefix keeps its whitespace boundary,
|
|
853
|
+
// or `git push` would cover `git pushall`. Found by a Copilot review comment on #31.
|
|
854
|
+
//
|
|
855
|
+
// Each spelling is tested WHOLE and per segment. Whole, because a path-prefix target is
|
|
856
|
+
// matched against the command as written; per segment, because a gated command that is not
|
|
857
|
+
// the first thing on the line is still that command — see `commandSegments`.
|
|
858
|
+
const hit = (s) =>
|
|
859
|
+
action.shell.endsWith("/") ? s.startsWith(action.shell) : s === action.shell || s.startsWith(`${action.shell} `);
|
|
860
|
+
// `spellings` is applied to each SEGMENT as well as to the whole line, and the second call is
|
|
861
|
+
// not redundant. Unwrapping was anchored at the start of the command, so a wrapper that was
|
|
862
|
+
// not the first thing on the line never got unwrapped: `ls && bash -c "git push --force
|
|
863
|
+
// origin main"` stepped aside, while both halves of it worked alone — the wrapper spelling is
|
|
864
|
+
// hole 1's "one level, peeled", and mid-line reach is hole 2. Two claims that each held and
|
|
865
|
+
// did not compose, which is how a gap survives a reader checking either one.
|
|
866
|
+
//
|
|
867
|
+
// Found while writing a regression test for the `$'…'` form Copilot reported; the plain-quote
|
|
868
|
+
// spelling turned out to escape the same way, so this is wider than the report.
|
|
869
|
+
//
|
|
870
|
+
// **Both calls read the RAW command, and that is the whole care in this line.** The first
|
|
871
|
+
// draft segmented each *spelling* and unwrapped again — which peeled TWO levels for a nested
|
|
872
|
+
// wrapper and made `bash -c "bash -c 'git push origin HEAD'"` match, contradicting hole 1's
|
|
873
|
+
// documented and asserted limit. The suite caught it, which is what that counterexample test
|
|
874
|
+
// is for. Reading the raw command in both branches keeps the budget at exactly one unwrap.
|
|
875
|
+
const segments = commandSegments(input.command);
|
|
876
|
+
return spellings(input.command).some(hit) || segments.some((seg) => spellings(seg).some(hit));
|
|
877
|
+
}
|
|
878
|
+
if (typeof action.write === "string") {
|
|
879
|
+
if (WRITE_TOOLS.includes(tool)) return matchesPath(input.file_path ?? input.notebook_path, action.write);
|
|
880
|
+
// A `write:` rule names a PATH, not a tool. For one milestone it reached only the three tools
|
|
881
|
+
// that carry a `file_path`, so `echo x >> docs/vision.md` through Bash was gated by neither
|
|
882
|
+
// layer: the permission rule rejects the tool, and this matcher fell through to `false`. The
|
|
883
|
+
// rule's own sentence is what that cost — an agent that can edit the constitution can launder
|
|
884
|
+
// any other change past its own grader — and it was reachable inside a session, with only the
|
|
885
|
+
// platform floor stopping it from landing. See `shellWrites` above for what this covers and,
|
|
886
|
+
// more to the point, for what it does not.
|
|
887
|
+
if (tool === "Bash") {
|
|
888
|
+
// Whole line and per segment, exactly as the `shell` branch above — and for the same
|
|
889
|
+
// reason, arrived at the hard way. That branch grew segment composition when a wrapper
|
|
890
|
+
// that was not first on the line turned out to escape it. This branch did not, and the
|
|
891
|
+
// gap it left is the worse of the two: `git status; bash -c "echo x >> docs/vision.md"`
|
|
892
|
+
// stepped aside where the same wrapper alone answers `deny`, so the CONSTITUTION was
|
|
893
|
+
// reachable behind any separator plus one wrapper. Measured 2026-07-28.
|
|
894
|
+
//
|
|
895
|
+
// A fix landing in one carrier and not its sibling is the defect class this repository
|
|
896
|
+
// has a standing ruling about; it happened here between two branches of one function,
|
|
897
|
+
// five commits apart. Found by Copilot review on #60, not by the session that wrote the
|
|
898
|
+
// first half.
|
|
899
|
+
//
|
|
900
|
+
// Both calls read the RAW command so the unwrap budget stays at one level, which is what
|
|
901
|
+
// keeps hole 1's two-wrapper counterexample true on this side too.
|
|
902
|
+
const writes = (s) => shellWrites(s, action.write);
|
|
903
|
+
return (
|
|
904
|
+
spellings(input.command).some(writes) ||
|
|
905
|
+
commandSegments(input.command).some((seg) => spellings(seg).some(writes))
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (typeof action.read === "string" && READ_TOOLS.includes(tool)) {
|
|
910
|
+
return matchesPath(input.file_path, action.read);
|
|
911
|
+
}
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ===========================================================================================
|
|
916
|
+
// 3. The backends
|
|
917
|
+
// ===========================================================================================
|
|
918
|
+
//
|
|
919
|
+
// A backend takes the parsed policy and returns one shape, always:
|
|
920
|
+
//
|
|
921
|
+
// { backend, label, compiled: [{id, tier, surface}], refused: [{id, tier, why}],
|
|
922
|
+
// notes: [string], artifact: { path, value, text } | null }
|
|
923
|
+
//
|
|
924
|
+
// `compiled` and `refused` together always equal the input, per backend — asserted by the suite,
|
|
925
|
+
// because the distinctive failure of a compiler that emits gate machinery is a rule that goes in
|
|
926
|
+
// and nothing comes out, leaving a gate map that reads as configured and a machine that enforces
|
|
927
|
+
// nothing. The uniform shape is what lets the matrix and `doctor` read every backend the same way
|
|
928
|
+
// without either of them knowing what a backend does.
|
|
929
|
+
|
|
930
|
+
/** A workspace-relative path, as the host spells it. A trailing `/` means the subtree. */
|
|
931
|
+
function pathSpec(target) {
|
|
932
|
+
const clean = target.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
933
|
+
return clean.endsWith("/") ? `./${clean}**` : `./${clean}`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** A workspace-relative path becomes a host permission pattern. */
|
|
937
|
+
function pattern(tool, target) {
|
|
938
|
+
return `${tool}(${pathSpec(target)})`;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Which tiers this backend gates. `auto` and `propose` are refused wholesale, on the maintainer's
|
|
942
|
+
// ruling of 2026-07-27: the compiler emits restriction only, never permission. A compiler whose
|
|
943
|
+
// output can only ever ADD a gate cannot loosen an existing check by having a bug.
|
|
944
|
+
//
|
|
945
|
+
// ## What the ruling costs — which this comment used to leave to the reader's imagination
|
|
946
|
+
//
|
|
947
|
+
// Refusing to emit `allow` does not make the Auto tier free. It moves the tier's price off this
|
|
948
|
+
// repository. The host prompts for any command it has not been told about, so every Auto action is
|
|
949
|
+
// answered by hand, and those answers pile up in the host's own settings files, which are
|
|
950
|
+
// per-machine, unreviewable, absent from every diff, and — for a workflow built on throwaway
|
|
951
|
+
// worktrees — lost along with the branch that earned them. Measured on one host, 2026-07-28: 404 hand-added allow
|
|
952
|
+
// entries, of which exactly ONE corresponded to an Auto rule in ../.portulan/gates.json. So the
|
|
953
|
+
// tier is unattended in POLICY and heavily attended in PRACTICE, and those are not the same claim.
|
|
954
|
+
// The refusal string below used to make the first one and read as the second.
|
|
955
|
+
//
|
|
956
|
+
// ## The ruling stands, on a different sentence than before
|
|
957
|
+
//
|
|
958
|
+
// "Emitting `allow` would loosen a check" is not the hazard, or not obviously so: `git push` is
|
|
959
|
+
// Auto and `git push --force` is Gated, and whether a narrower `ask` still outranks a broader
|
|
960
|
+
// `allow` is a host precedence question **this repository has never measured** — so no argument
|
|
961
|
+
// here should rest on it. The hazard that does not depend on that answer: an allow prefix reaches
|
|
962
|
+
// every spelling beneath it, including the ones no rule here names. `git push --mirror` is
|
|
963
|
+
// destructive and appears in no tier of ../.portulan/gates.json. An allow on the prefix would
|
|
964
|
+
// clear it silently, and nothing in this policy would notice.
|
|
965
|
+
const HOST_GATE_TIERS = new Set(["gated", "prohibited"]);
|
|
966
|
+
|
|
967
|
+
const HOST_TIER_NOT_A_GATE = {
|
|
968
|
+
auto: "tier `auto` is unattended by policy, not by the host — this backend emits no `allow` rule for it, which is what keeps the compiler additive, and the prompts that omission leaves are paid by hand in the host's own settings, outside this repository",
|
|
969
|
+
propose: "tier `propose` is enforced by the platform floor — pull requests, required checks, review — not by a tool-level permission rule on this machine",
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Translate the policy into a Claude Code settings object.
|
|
974
|
+
*
|
|
975
|
+
* The mapping, each line of it measured against a running host rather than read from documentation:
|
|
976
|
+
*
|
|
977
|
+
* prohibited -> permissions.deny + a hook returning `deny`
|
|
978
|
+
* gated -> permissions.ask + a hook returning `ask`
|
|
979
|
+
*
|
|
980
|
+
* The hook returns the SAME decision as the permission rule on purpose. A hook returning `deny` for
|
|
981
|
+
* a Gated action would turn a per-action prompt into a hard block, which is the tier above it.
|
|
982
|
+
*
|
|
983
|
+
* One line of that mapping has no permission half. A `write:` rule also gates the SHELL spellings of
|
|
984
|
+
* a write — see `shellWrites` above — and no `Bash(prefix:*)` pattern can express "a command writing
|
|
985
|
+
* this path", so that half is the hook's alone and fails open with it. Emitted anyway, because the
|
|
986
|
+
* alternative was leaving `echo x >> docs/vision.md` gated by nothing at all, and reported in the
|
|
987
|
+
* backend's `notes` on every run so the weaker layer is never inferred from silence.
|
|
988
|
+
*/
|
|
989
|
+
export function claudeCode(parsed, options = {}) {
|
|
990
|
+
// The header names the policy file that was ACTUALLY read. It was a literal for one round, so a
|
|
991
|
+
// workspace declaring a non-default policy got an artifact claiming it came from somewhere it did
|
|
992
|
+
// not — in the one field whose entire job is telling a reader what generated this. Found by review.
|
|
993
|
+
const source = options.source ?? ".portulan/gates.json";
|
|
994
|
+
// **Where the emitted hook finds its runner, and why this is computed rather than written down.**
|
|
995
|
+
//
|
|
996
|
+
// Both runners used to live at `.portulan/compile/`, which `package.json`'s `files` has never
|
|
997
|
+
// shipped — so every adopter's compiled policy named two files they did not receive, and a missing
|
|
998
|
+
// hook FAILS OPEN. Milestone 7 moves them into `cli/`, which does ship. That fixes what the adopter
|
|
999
|
+
// has; it does not by itself fix how the hook names it, because `cli/` sits in a different place in
|
|
1000
|
+
// each of the three contexts this must survive:
|
|
1001
|
+
//
|
|
1002
|
+
// 1. this checkout — `<project>/cli/`
|
|
1003
|
+
// 2. a project-local install — `<project>/node_modules/@sleepy_panda_srl/portulan/cli/`
|
|
1004
|
+
// 3. a global or npx-only install — NOT under the project at all
|
|
1005
|
+
//
|
|
1006
|
+
// So the path is derived from where THIS file actually is at compile time and expressed relative to
|
|
1007
|
+
// the project when it lands inside it — covering 1 and 2 with one rule rather than two special
|
|
1008
|
+
// cases. For 3 there is no project-relative spelling that exists, so an absolute path is emitted and
|
|
1009
|
+
// a **note** records that the hook is pinned to this machine — see the `pinned` push below. Naming
|
|
1010
|
+
// that is the point: a hook silently pinned to an absolute path is a policy that stops working when
|
|
1011
|
+
// the package moves, and finding out by having no gate is the worst way to find out.
|
|
1012
|
+
//
|
|
1013
|
+
// This sentence said `refused` for one round, and `refused` carries rule-level compilation refusals
|
|
1014
|
+
// only — nothing ever added the runner path to it. The pre-commit checkpoint found the recording
|
|
1015
|
+
// missing entirely; the fix added it on the `notes` channel and left this line still naming the
|
|
1016
|
+
// wrong one, so a reader following the comment would have looked in a place that never holds it.
|
|
1017
|
+
// Copilot's round caught the remainder. A fix that leaves its own explanation pointing elsewhere is
|
|
1018
|
+
// half a fix.
|
|
1019
|
+
const runnerDir = path.dirname(fileURLToPath(import.meta.url));
|
|
1020
|
+
// The project this policy is being compiled FOR. `claudeCode` is handed a parsed policy and not a
|
|
1021
|
+
// location, so the caller may say; `cwd` is the honest default because `compile` is run from the
|
|
1022
|
+
// repository it compiles.
|
|
1023
|
+
const project = options.root ?? process.cwd();
|
|
1024
|
+
const pinned = [];
|
|
1025
|
+
const spell = (file) => {
|
|
1026
|
+
const abs = path.join(runnerDir, file);
|
|
1027
|
+
const rel = path.relative(project, abs);
|
|
1028
|
+
// `rel.split(sep)[0] !== ".."` rather than `!startsWith("..")` — the latter also rejects a
|
|
1029
|
+
// directory literally named `..foo`. Benign in direction (it would fall back to absolute), but
|
|
1030
|
+
// the exact spelling costs nothing and the loose one is copied from here.
|
|
1031
|
+
const inside = rel && rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel);
|
|
1032
|
+
if (inside) return `"\${CLAUDE_PROJECT_DIR}/${rel.split(path.sep).join("/")}"`;
|
|
1033
|
+
pinned.push({ file, abs });
|
|
1034
|
+
return `"${abs}"`;
|
|
1035
|
+
};
|
|
1036
|
+
const runner = options.runner ?? spell("gate.mjs");
|
|
1037
|
+
const stopRunner = options.stopRunner ?? spell("stop-gate.mjs");
|
|
1038
|
+
|
|
1039
|
+
const compiled = [];
|
|
1040
|
+
const refused = [];
|
|
1041
|
+
const deny = [];
|
|
1042
|
+
const ask = [];
|
|
1043
|
+
const matchers = new Set();
|
|
1044
|
+
const shellWriteGates = [];
|
|
1045
|
+
|
|
1046
|
+
for (const rule of parsed.rules) {
|
|
1047
|
+
if (!HOST_GATE_TIERS.has(rule.tier)) {
|
|
1048
|
+
refused.push({ id: rule.id, tier: rule.tier, why: HOST_TIER_NOT_A_GATE[rule.tier] });
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
if (rule.kind === "none") {
|
|
1052
|
+
// The policy itself states why there is no surface. The compiler reports those words
|
|
1053
|
+
// rather than inventing its own — a refusal explained by the tool is a refusal nobody
|
|
1054
|
+
// can review against the policy.
|
|
1055
|
+
refused.push({ id: rule.id, tier: rule.tier, why: rule.target });
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const into = rule.tier === "prohibited" ? deny : ask;
|
|
1059
|
+
const emitted = []; // permission patterns — the layer that cannot fail open
|
|
1060
|
+
const hookOnly = []; // coverage the permission DSL cannot express, carried by the hook alone
|
|
1061
|
+
if (rule.kind === "shell") {
|
|
1062
|
+
emitted.push(`Bash(${rule.target}:*)`);
|
|
1063
|
+
matchers.add("Bash");
|
|
1064
|
+
} else {
|
|
1065
|
+
for (const tool of rule.kind === "write" ? WRITE_TOOLS : READ_TOOLS) {
|
|
1066
|
+
emitted.push(pattern(tool, rule.target));
|
|
1067
|
+
matchers.add(tool);
|
|
1068
|
+
}
|
|
1069
|
+
if (rule.kind === "write") {
|
|
1070
|
+
// The hook is wired for Bash so that a shell spelling of this write reaches
|
|
1071
|
+
// `matchesRule`, which now answers for it. **This line is the load-bearing half.**
|
|
1072
|
+
// The matcher alone would be inert in any workspace whose policy declares no shell
|
|
1073
|
+
// gate: `Bash` would be absent from the matchers below, the runner would never be
|
|
1074
|
+
// invoked for a Bash call, and the coverage would exist only in a function nothing
|
|
1075
|
+
// calls — the manifest-field-that-validates-and-loads-nothing defect
|
|
1076
|
+
// (../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md), arriving
|
|
1077
|
+
// this time as a matcher nothing reaches. It changes no artifact in THIS repository,
|
|
1078
|
+
// whose policy already gates shell commands, which is precisely why it would not have
|
|
1079
|
+
// been noticed here.
|
|
1080
|
+
//
|
|
1081
|
+
// No permission pattern joins it, and that is not an omission: `Bash(prefix:*)`
|
|
1082
|
+
// matches a literal command prefix while the path sits at an arbitrary position, so
|
|
1083
|
+
// the DSL has no way to say "any command writing this file". The only patterns that
|
|
1084
|
+
// would fit — `Bash(cp:*)`, `Bash(sed -i:*)` — gate the utility rather than the path,
|
|
1085
|
+
// which is a different and much larger rule than the policy declares.
|
|
1086
|
+
matchers.add("Bash");
|
|
1087
|
+
hookOnly.push(`hook: a Bash command writing ${pathSpec(rule.target)}`);
|
|
1088
|
+
shellWriteGates.push(rule.id);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
into.push(...emitted);
|
|
1092
|
+
// The hook-only clause is carried in the SURFACE and not in `into`. It is not a permission
|
|
1093
|
+
// pattern, and a reader of the matrix has to be able to tell which half of a gate would
|
|
1094
|
+
// survive a broken hook.
|
|
1095
|
+
compiled.push({ id: rule.id, tier: rule.tier, surface: [...emitted, ...hookOnly].join(" · ") });
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// A policy that declares gates and emits none must not report success. This is the workflow's
|
|
1099
|
+
// "declares no verify recipes — refusing to report green" one level down: the artifact would be
|
|
1100
|
+
// written, the gate map would read as compiled, and nothing would hold.
|
|
1101
|
+
if (parsed.rules.some((r) => HOST_GATE_TIERS.has(r.tier)) && compiled.length === 0) {
|
|
1102
|
+
throw new CompileError(
|
|
1103
|
+
"every gate in this policy refused to compile — refusing to write an artifact that enforces nothing while the policy claims gates",
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// The generation header. An emitted artifact that does not say what generated it invites the
|
|
1108
|
+
// one edit this whole rail exists to catch — a hand-fix that survives until the next compile
|
|
1109
|
+
// silently reverts it.
|
|
1110
|
+
const value = {
|
|
1111
|
+
$portulan: {
|
|
1112
|
+
generated: "cli/compile.mjs",
|
|
1113
|
+
source,
|
|
1114
|
+
warning: `Generated file. Edit ${source} and recompile; \`verify/compile.sh\` fails on drift.`,
|
|
1115
|
+
},
|
|
1116
|
+
permissions: { deny, ask, allow: [] },
|
|
1117
|
+
hooks: {
|
|
1118
|
+
// Quoted deliberately: this repository's own working copy lives under a path with
|
|
1119
|
+
// spaces in it, and an unquoted expansion would word-split into a runner nobody can
|
|
1120
|
+
// find. No pipes, redirections or separators — an emitted shell one-liner is where the
|
|
1121
|
+
// next quoting fail-open would live (../.portulan/tasks/0004).
|
|
1122
|
+
PreToolUse: [...matchers].sort().map((matcher) => ({
|
|
1123
|
+
matcher,
|
|
1124
|
+
hooks: [{ type: "command", command: `node ${runner}` }],
|
|
1125
|
+
})),
|
|
1126
|
+
Stop: [{ hooks: [{ type: "command", command: `node ${stopRunner}` }] }],
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
// The shell half of every write gate, reported on every run rather than left for a reader to
|
|
1131
|
+
// infer from the absence of a `Bash` entry in `deny`. It is the one place in this artifact where
|
|
1132
|
+
// a gate is carried by the layer that fails open, so a note that only appeared when something
|
|
1133
|
+
// went wrong would be a note nobody ever reads.
|
|
1134
|
+
const notes = [];
|
|
1135
|
+
// **The promise this comment used to make and the code did not keep.** The fallback above emits an
|
|
1136
|
+
// absolute path when the runner is not under the project — a global or npx-only install — and the
|
|
1137
|
+
// paragraph at the top of this function said `refused` would record that the hook is pinned to this
|
|
1138
|
+
// machine. It recorded nothing: the pre-commit checkpoint compiled from a package outside a project
|
|
1139
|
+
// and got two absolute hooks with **zero output about it**. A hook silently pinned to one machine is
|
|
1140
|
+
// a policy that stops working when the package moves, and finding out by having no gate is the worst
|
|
1141
|
+
// way to find out — so it is said, on the channel that prints every run.
|
|
1142
|
+
if (pinned.length) {
|
|
1143
|
+
notes.push(
|
|
1144
|
+
`${pinned.length} hook(s) are pinned to an ABSOLUTE path on this machine — ` +
|
|
1145
|
+
`${pinned.map((x) => x.abs).join(", ")}. The runner is not under this project (a global or \`npx\` install), ` +
|
|
1146
|
+
`so no \`\${CLAUDE_PROJECT_DIR}\`-relative spelling exists. The compiled policy therefore stops working if the ` +
|
|
1147
|
+
`package moves or is reinstalled elsewhere, and a missing hook FAILS OPEN. Install the package into the ` +
|
|
1148
|
+
`project, or pass \`--runner\`/\`--stop-runner\` to name a path you control`,
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
if (shellWriteGates.length) {
|
|
1152
|
+
notes.push(
|
|
1153
|
+
`${shellWriteGates.length} write gate(s) — ${shellWriteGates.join(", ")} — also match a Bash command that writes the ` +
|
|
1154
|
+
`path: a \`>\`/\`>>\` redirection into it, or one of \`${[...FILE_WRITERS].join("`, `")}\` naming it ` +
|
|
1155
|
+
`OR naming a directory it lives in (\`rm -rf docs\` reaches \`docs/vision.md\`), or ` +
|
|
1156
|
+
`\`${[...IN_PLACE_EDITORS].join("`/`")}\` under an in-place flag. This half is the HOOK's alone and therefore ` +
|
|
1157
|
+
`FAILS OPEN if the hook does: \`Bash(prefix:*)\` matches a command prefix while the path sits anywhere in the ` +
|
|
1158
|
+
`command, so no permission rule expresses it. A heredoc whose TARGET is interpolated, an interpolated ` +
|
|
1159
|
+
`variable, a command assembled at ` +
|
|
1160
|
+
`runtime, a runtime writing the file itself (\`python3 -c\`), or any writer outside that table still reaches ` +
|
|
1161
|
+
`the path. The platform floor is what covers those.`,
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
return {
|
|
1166
|
+
backend: "claude-code",
|
|
1167
|
+
label: "Claude Code",
|
|
1168
|
+
compiled,
|
|
1169
|
+
refused,
|
|
1170
|
+
notes,
|
|
1171
|
+
artifact: { path: ARTIFACT_PATHS["claude-code"], value, text: render(value) },
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// ===========================================================================================
|
|
1176
|
+
// 3b. The floor backend — a GitHub repository ruleset
|
|
1177
|
+
// ===========================================================================================
|
|
1178
|
+
//
|
|
1179
|
+
// The milestone-4 criterion positions this as **the floor backend**: what every host falls back to,
|
|
1180
|
+
// and all that a host with no hook system has. `../core/operating/autonomy.md` says the floor is the
|
|
1181
|
+
// floor "because it holds when everything above it fails", and promises that the enforcement compiler
|
|
1182
|
+
// reads the workspace's gate policy and generates the host's own enforcement; this is that promise,
|
|
1183
|
+
// discharged for the floor half. (Quoted rather than paraphrased-in-quotes, which is the small version
|
|
1184
|
+
// of the defect ../.portulan/memory/a-stated-enforcer-must-be-the-real-one.md is about.)
|
|
1185
|
+
//
|
|
1186
|
+
// It **generates, never applies.** Importing a ruleset is a repository-settings change — outward,
|
|
1187
|
+
// Gated, the maintainer's, per the proposal-0001 precedent — so this emits a file and stops.
|
|
1188
|
+
//
|
|
1189
|
+
// Only the server's *input* fields are emitted: an export carrying an `id` or a `created_at` would be
|
|
1190
|
+
// asserting facts it cannot know. The envelope and that omission list were read from two live rulesets
|
|
1191
|
+
// on 2026-07-27; the `pull_request` and `required_status_checks` parameter blocks were **not** — neither
|
|
1192
|
+
// live ruleset carries those rules — so those come from GitHub's documented schema, and
|
|
1193
|
+
// ../.portulan/compile/README.md marks them as such rather than folding them into "read from live".
|
|
1194
|
+
//
|
|
1195
|
+
// **Recognition is by exact spelling, and that is a limit rather than an oversight.** The action
|
|
1196
|
+
// vocabulary has no `ref` kind — a rule says `{"shell": "git push --force"}` — so this backend
|
|
1197
|
+
// matches command strings against a table and refuses everything else out loud. A parser that
|
|
1198
|
+
// recognised `git push -f` as the same action would be the ambitious parser `../spec/slots.md`
|
|
1199
|
+
// warns against, and it would buy confidence with false reds.
|
|
1200
|
+
const REF_RULES = new Map([
|
|
1201
|
+
["git push --force", "non_fast_forward"],
|
|
1202
|
+
["git push --delete", "deletion"],
|
|
1203
|
+
]);
|
|
1204
|
+
|
|
1205
|
+
// Which tiers this backend may emit a ref rule for. `prohibited` is included and `auto` is not:
|
|
1206
|
+
// a prohibition on a ref operation is a restriction the floor can carry, while an unattended action
|
|
1207
|
+
// needs no ref gate by definition.
|
|
1208
|
+
const FLOOR_GATE_TIERS = new Set(["gated", "prohibited"]);
|
|
1209
|
+
|
|
1210
|
+
/** Commands a *tag* ruleset would reach. Named individually so the refusal can say which mechanism. */
|
|
1211
|
+
const TAG_SHAPED = new Set(["git tag", "gh release"]);
|
|
1212
|
+
|
|
1213
|
+
export function githubRuleset(parsed, options = {}) {
|
|
1214
|
+
const source = options.source ?? ".portulan/gates.json";
|
|
1215
|
+
const { floor } = parsed;
|
|
1216
|
+
const compiled = [];
|
|
1217
|
+
const refused = [];
|
|
1218
|
+
const notes = [];
|
|
1219
|
+
|
|
1220
|
+
// No floor declared: refuse everything, by name, and write nothing. Defaulting the branch to
|
|
1221
|
+
// `main` was the obvious alternative and is the exact move this compiler must not make — a
|
|
1222
|
+
// compiler that invents the ref it gates has stopped compiling policy and started writing it.
|
|
1223
|
+
if (!floor) {
|
|
1224
|
+
for (const rule of parsed.rules) {
|
|
1225
|
+
refused.push({
|
|
1226
|
+
id: rule.id,
|
|
1227
|
+
tier: rule.tier,
|
|
1228
|
+
why: "this workspace declares no `floor` in its gate policy, so nothing names the ref a ruleset would protect. Declare `floor` (branch, checks, reviews, resolve_conversations) to compile a platform floor from this policy.",
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
return { backend: "github-ruleset", label: "GitHub repository ruleset", compiled, refused, notes, artifact: null };
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const rules = [];
|
|
1235
|
+
// Both halves of the condition are load-bearing, and the second was missing for one round. The
|
|
1236
|
+
// pair is **compiled from `propose` rules**, so a policy with none of them asked for no
|
|
1237
|
+
// pull-request requirement — emitting one because `floor.checks` happens to be declared is the
|
|
1238
|
+
// compiler inventing policy, which is the single thing this backend must not do. It also broke
|
|
1239
|
+
// the accounting silently: those two ruleset rules would have sat in the artifact with no policy
|
|
1240
|
+
// rule credited for compiling them, so the matrix and `doctor` would have described a floor
|
|
1241
|
+
// missing two of its own rules. Found by review, round 3.
|
|
1242
|
+
const declaresPropose = parsed.rules.some((r) => r.tier === "propose");
|
|
1243
|
+
const hasChecks = floor.checks.length > 0;
|
|
1244
|
+
const emitPullRequestPair = hasChecks && declaresPropose;
|
|
1245
|
+
|
|
1246
|
+
// The propose tier and the floor's pull-request pair. Emitted together or not at all: a
|
|
1247
|
+
// `pull_request` rule without `required_status_checks` imports cleanly, reads as a configured
|
|
1248
|
+
// floor, and lets a red pull request merge. Half a mapping is the silent weakening this
|
|
1249
|
+
// repository keeps finding, so the refusal names the missing declaration instead.
|
|
1250
|
+
if (emitPullRequestPair) {
|
|
1251
|
+
rules.push({
|
|
1252
|
+
type: "pull_request",
|
|
1253
|
+
parameters: {
|
|
1254
|
+
// The five GitHub requires on this rule, and no sixth. `allowed_merge_methods` is a
|
|
1255
|
+
// real optional parameter and was dropped deliberately: nothing in the policy drives
|
|
1256
|
+
// it and the criterion does not name it, so emitting it would widen the part of this
|
|
1257
|
+
// artifact that is invented rather than compiled, to buy nothing. (An earlier draft
|
|
1258
|
+
// also argued it "appears in neither live ruleset" — vacuous, since neither of those
|
|
1259
|
+
// carries a `pull_request` rule at all. Removed rather than kept as decoration.)
|
|
1260
|
+
required_approving_review_count: floor.reviews,
|
|
1261
|
+
dismiss_stale_reviews_on_push: true,
|
|
1262
|
+
require_code_owner_review: false,
|
|
1263
|
+
require_last_push_approval: false,
|
|
1264
|
+
required_review_thread_resolution: floor.resolve_conversations,
|
|
1265
|
+
},
|
|
1266
|
+
});
|
|
1267
|
+
rules.push({
|
|
1268
|
+
type: "required_status_checks",
|
|
1269
|
+
parameters: {
|
|
1270
|
+
// Not read from the policy, and deliberately: a pull request may not merge from
|
|
1271
|
+
// behind its base (proposal 0011, applied live 2026-07-27). A declarable `strict`
|
|
1272
|
+
// would let a later policy edit undo a ruling with no diff anyone would read as one.
|
|
1273
|
+
strict_required_status_checks_policy: true,
|
|
1274
|
+
do_not_enforce_on_create: false,
|
|
1275
|
+
required_status_checks: floor.checks,
|
|
1276
|
+
},
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
for (const rule of parsed.rules) {
|
|
1281
|
+
if (rule.tier === "propose") {
|
|
1282
|
+
if (emitPullRequestPair) {
|
|
1283
|
+
compiled.push({ id: rule.id, tier: rule.tier, surface: "pull_request · required_status_checks" });
|
|
1284
|
+
} else {
|
|
1285
|
+
refused.push({
|
|
1286
|
+
id: rule.id,
|
|
1287
|
+
tier: rule.tier,
|
|
1288
|
+
why: "this floor declares no status check, and `pull_request` is emitted only together with `required_status_checks` — requiring a pull request while requiring nothing green of it imports cleanly and reads as a floor. Declare `floor.checks`.",
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// The TIER is consulted before the spelling table, and that order is the whole of it. It was
|
|
1295
|
+
// the other way round for one round, so an `auto` rule spelled exactly `git push --force`
|
|
1296
|
+
// compiled into `non_fast_forward` — a ruleset rule emitted for an action the policy declares
|
|
1297
|
+
// unattended, with `floorRefusal`'s own `auto` branch left unreachable for it. The table is
|
|
1298
|
+
// only how this backend spells a gate; whether there is a gate at all is the policy's answer.
|
|
1299
|
+
// Found by review on the pull request.
|
|
1300
|
+
const shell = rule.kind === "shell" && FLOOR_GATE_TIERS.has(rule.tier) ? rule.target : null;
|
|
1301
|
+
const refType = shell ? REF_RULES.get(shell) : undefined;
|
|
1302
|
+
if (refType) {
|
|
1303
|
+
rules.push({ type: refType });
|
|
1304
|
+
compiled.push({ id: rule.id, tier: rule.tier, surface: refType });
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
refused.push({ id: rule.id, tier: rule.tier, why: floorRefusal(rule, floor) });
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
if (compiled.length === 0) {
|
|
1312
|
+
throw new CompileError(
|
|
1313
|
+
`this workspace declares a floor on \`${floor.branch}\` and no rule in the policy reaches it — refusing to write an importable ruleset that enforces nothing`,
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// The coarseness, recorded in BOTH directions. A backend that reported only where it is weaker
|
|
1318
|
+
// than the policy would be flattering itself, and the stricter direction is the one an adopter
|
|
1319
|
+
// is surprised by.
|
|
1320
|
+
if (compiled.some((c) => c.surface === "non_fast_forward")) {
|
|
1321
|
+
notes.push(
|
|
1322
|
+
`on \`refs/heads/${floor.branch}\` the \`non_fast_forward\` rule is STRICTER than this policy: it blocks every force-push, including \`git push --force-with-lease\`, which the policy classifies Auto. The floor gates a ref and cannot read a command's flags.`,
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
notes.push(
|
|
1326
|
+
`every rule here applies to one declared ref, \`refs/heads/${floor.branch}\`, and to nothing else. A policy rule naming an action on any other branch is not covered by this export even where it compiled.`,
|
|
1327
|
+
);
|
|
1328
|
+
// A declaration nothing compiles is reported, never dropped in silence — the manifest-field-that-
|
|
1329
|
+
// loads-nothing defect, arriving from the policy side this time.
|
|
1330
|
+
if (hasChecks && !declaresPropose) {
|
|
1331
|
+
notes.push(
|
|
1332
|
+
`this floor declares ${floor.checks.length} status check(s) and the policy carries no \`propose\` rule, so no ` +
|
|
1333
|
+
`\`pull_request\` or \`required_status_checks\` rule is emitted. Requiring checks presupposes pull requests, and ` +
|
|
1334
|
+
`this backend will not add that requirement on a workspace's behalf. The checks are a declaration nothing compiles.`,
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
notes.push(
|
|
1338
|
+
"this export carries the three rule types the milestone-4 criterion names and no more. It does not reproduce a repository's whole protection surface, so importing it beside classic branch protection ADDS a layer rather than replacing one — and removing classic protection afterwards would drop whatever this ruleset does not carry.",
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
const value = {
|
|
1342
|
+
// JSON has no comments and a GitHub ruleset has no description field, so the name is the
|
|
1343
|
+
// only place a warning survives into the settings UI a maintainer actually reads. Same job
|
|
1344
|
+
// as the `$portulan` header in the other artifact, done in the one field available.
|
|
1345
|
+
name: `portulan floor — ${floor.branch} (generated from ${source})`,
|
|
1346
|
+
target: "branch",
|
|
1347
|
+
enforcement: "active",
|
|
1348
|
+
conditions: { ref_name: { include: [`refs/heads/${floor.branch}`], exclude: [] } },
|
|
1349
|
+
rules,
|
|
1350
|
+
// Unconditional, and the gate map's own sentence is why: a floor carrying an exemption for
|
|
1351
|
+
// the only actor who can act is not a floor. The organisation-level ruleset this repository
|
|
1352
|
+
// sits under does carry an `OrganizationAdmin` always-bypass, which is recorded there as the
|
|
1353
|
+
// unverified layer of the floor — not copied here.
|
|
1354
|
+
bypass_actors: [],
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1357
|
+
return {
|
|
1358
|
+
backend: "github-ruleset",
|
|
1359
|
+
label: "GitHub repository ruleset",
|
|
1360
|
+
compiled,
|
|
1361
|
+
refused,
|
|
1362
|
+
notes,
|
|
1363
|
+
artifact: { path: artifactPaths(options.workspaceDir ?? ".portulan")["github-ruleset"], value, text: render(value) },
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Why one rule does not reach the floor.
|
|
1369
|
+
*
|
|
1370
|
+
* Every sentence here is scoped to *this export* rather than to GitHub, because
|
|
1371
|
+
* `../.portulan/memory/a-stated-enforcer-must-be-the-real-one.md` binds any sentence containing
|
|
1372
|
+
* "cannot" — and the convenient blanket version ("the platform gates a ref, not a path") is simply
|
|
1373
|
+
* false: CODEOWNERS gates owned paths and is named as part of the floor by
|
|
1374
|
+
* `../core/operating/autonomy.md`, push rulesets gate file paths, and tag rulesets gate `refs/tags/*`.
|
|
1375
|
+
*/
|
|
1376
|
+
function floorRefusal(rule, floor) {
|
|
1377
|
+
if (rule.tier === "auto") {
|
|
1378
|
+
// "Unattended by definition" is literally true on THIS backend, and true only here: a branch
|
|
1379
|
+
// ruleset has no prompt to pay, so an Auto rule left out of it costs nobody anything. The
|
|
1380
|
+
// host backend carried the identical phrase and it was false there — see the comment above
|
|
1381
|
+
// `HOST_GATE_TIERS`. Same words, one backend apart, one of them wrong. Do not carry either
|
|
1382
|
+
// version across without re-deriving it for the backend it lands in.
|
|
1383
|
+
return "tier `auto` is unattended by definition — the floor exists to refuse what an agent may not do alone, and an action needing nobody's approval needs no ref gate.";
|
|
1384
|
+
}
|
|
1385
|
+
if (rule.kind === "none") {
|
|
1386
|
+
return `${rule.target} No branch ruleset reaches it either.`;
|
|
1387
|
+
}
|
|
1388
|
+
if (rule.kind === "write" || rule.kind === "read") {
|
|
1389
|
+
return `this export compiles BRANCH rules for one declared ref. A path-scoped guarantee on GitHub is \`CODEOWNERS\` (via a pull-request rule's code-owner review) or a push ruleset, neither of which this export emits — this repository's \`CODEOWNERS\` is deliberately non-enforcing, which is a separate decision from this one. Out of scope, not beyond the platform.`;
|
|
1390
|
+
}
|
|
1391
|
+
if (TAG_SHAPED.has(rule.target)) {
|
|
1392
|
+
return "this action creates a tag, which a **tag ruleset** targeting `refs/tags/*` would gate. This export emits one branch ruleset and does not emit tag rulesets, so the rule is out of its scope.";
|
|
1393
|
+
}
|
|
1394
|
+
if (rule.target === "gh pr merge") {
|
|
1395
|
+
return `the floor CONSTRAINS this action — a merge into \`${floor.branch}\` needs its required checks green and, with strict checks, a head that is not behind the base — but with ${floor.reviews} required approving review(s) it does not require a human's yes, which is what the Gated tier means. Reported as not compiled rather than as covered, because the guarantee the rule asks for is not one a branch ruleset makes.`;
|
|
1396
|
+
}
|
|
1397
|
+
return `no branch-ruleset rule corresponds to \`${rule.target}\`, and recognition here is by EXACT command spelling (\`${[...REF_RULES.keys()].join("`, `")}\`) rather than by parsing — a matcher clever enough to generalise would be clever enough to be wrong quietly.`;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Where each backend's artifact lives, whether or not this policy produces one.
|
|
1402
|
+
*
|
|
1403
|
+
* Declared beside the backends rather than read off a result, because `--check` has to look for an
|
|
1404
|
+
* artifact a backend *no longer* owes — and a backend that emits nothing has no result to read a
|
|
1405
|
+
* path from. Keyed by backend id so adding a backend without a path here is a `TypeError` at the
|
|
1406
|
+
* first check rather than a silently unswept file.
|
|
1407
|
+
*/
|
|
1408
|
+
const ARTIFACT_PATHS = {
|
|
1409
|
+
"claude-code": ".claude/settings.json",
|
|
1410
|
+
"github-ruleset": ".portulan/compile/github-ruleset.json",
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* The same map, for a workspace that does not live at `.portulan`.
|
|
1415
|
+
*
|
|
1416
|
+
* **A feature that dispatches on residence is a parity breach**, and proposal
|
|
1417
|
+
* `../.portulan/proposals/0017-one-repository-one-governing-workspace.md` says it is refusable on that
|
|
1418
|
+
* sentence alone. This one did: the ruleset's path had `.portulan` written into it, so compiling a
|
|
1419
|
+
* feed-side workspace looked for a gate policy at `<feed>/workspaces/.portulan/gates.json` — a
|
|
1420
|
+
* directory that does not exist and never would — and exited 2, *could not run*, on a workspace that
|
|
1421
|
+
* `doctor`, `index` and its own verify recipe all handled identically at both ends.
|
|
1422
|
+
*
|
|
1423
|
+
* **Found by running row 7's fourth demonstration, not by reading this file.** Every other line of the
|
|
1424
|
+
* compiler already keyed to a slot; this one key was location. The default is unchanged, so a caller
|
|
1425
|
+
* that names no workspace directory takes a byte-identical path to the one it took before.
|
|
1426
|
+
*/
|
|
1427
|
+
function artifactPaths(workspaceDir) {
|
|
1428
|
+
if (workspaceDir === ".portulan") return ARTIFACT_PATHS;
|
|
1429
|
+
// A workspace directory that IS the root — the feed-side shape, where the workspace ships as the
|
|
1430
|
+
// plugin and the installed plugin's directory is the workspace root (milestone 6 measured that).
|
|
1431
|
+
// Its compiled artifacts belong beside it, because they ship with it.
|
|
1432
|
+
const prefix = workspaceDir === "." ? "" : `${workspaceDir}/`;
|
|
1433
|
+
return {
|
|
1434
|
+
"claude-code": ARTIFACT_PATHS["claude-code"],
|
|
1435
|
+
"github-ruleset": `${prefix}compile/github-ruleset.json`,
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** Every backend, run against one parsed policy. The order is the order the matrix prints in. */
|
|
1440
|
+
export function backends(parsed, options = {}) {
|
|
1441
|
+
return [claudeCode(parsed, options), githubRuleset(parsed, options)];
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* The per-host backend matrix: one row per policy rule, one cell per backend.
|
|
1446
|
+
*
|
|
1447
|
+
* Derived from the backends rather than maintained beside them. A matrix written by hand is a claim
|
|
1448
|
+
* about compilers, and this repository has spent two milestones on what claims about machinery are
|
|
1449
|
+
* worth — so this one cannot drift from the compilers, because it *is* their accounting transposed.
|
|
1450
|
+
*/
|
|
1451
|
+
export function matrix(parsed, options = {}) {
|
|
1452
|
+
const columns = backends(parsed, options);
|
|
1453
|
+
return parsed.rules.map((rule) => {
|
|
1454
|
+
const cells = {};
|
|
1455
|
+
for (const column of columns) {
|
|
1456
|
+
const hit = column.compiled.find((c) => c.id === rule.id);
|
|
1457
|
+
cells[column.backend] = hit
|
|
1458
|
+
? { verdict: "compiled", detail: hit.surface }
|
|
1459
|
+
: { verdict: "refused", detail: column.refused.find((r) => r.id === rule.id)?.why ?? "" };
|
|
1460
|
+
}
|
|
1461
|
+
return { id: rule.id, tier: rule.tier, backends: cells };
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// ===========================================================================================
|
|
1466
|
+
// 3. The command line
|
|
1467
|
+
// ===========================================================================================
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Where a workspace's gate policy lives.
|
|
1471
|
+
*
|
|
1472
|
+
* Read from the manifest's top-level `gates` key, because that is what the key MEANS —
|
|
1473
|
+
* `../spec/slots.md` calls it "a path to a JSON file the enforcement compiler reads", and `doctor`
|
|
1474
|
+
* resolves it. This compiler hard-coded `.portulan/gates.json` for one round, so a workspace naming a
|
|
1475
|
+
* different file would have had `doctor` validate one policy and `compile` compile another, with both
|
|
1476
|
+
* reporting green. A manifest key that validates and is never consumed is this repository's most
|
|
1477
|
+
* expensive recurring defect, and here it was in the very key this milestone added. Found by review on
|
|
1478
|
+
* the pull request.
|
|
1479
|
+
*
|
|
1480
|
+
* The default survives for a workspace with no manifest or no key — that is a legitimate shape, and
|
|
1481
|
+
* refusing it would make the key required, which is a spec change nobody decided.
|
|
1482
|
+
*/
|
|
1483
|
+
export function policyPath(workspaceRoot, workspaceDir = ".portulan") {
|
|
1484
|
+
const base = path.join(workspaceRoot, workspaceDir);
|
|
1485
|
+
const manifest = path.join(base, "workspace.json");
|
|
1486
|
+
try {
|
|
1487
|
+
const declared = JSON.parse(fs.readFileSync(manifest, "utf8")).gates;
|
|
1488
|
+
// Validated against the schema's `filePath` shape before it is used, and containment is
|
|
1489
|
+
// checked after resolution rather than by pattern alone — a `../` chain passes any regex and
|
|
1490
|
+
// still escapes. Without this, a malformed or hand-edited manifest turns a hook that runs on
|
|
1491
|
+
// EVERY tool call into an arbitrary file read outside the workspace. `doctor` would refuse such
|
|
1492
|
+
// a manifest, but this runner must not depend on `doctor` having been run: the two tools have
|
|
1493
|
+
// no ordering between them, and the schema is the contract, not the sequence. Found by review.
|
|
1494
|
+
if (typeof declared === "string" && declared.trim() && FILE_PATH.test(declared)) {
|
|
1495
|
+
const resolved = path.resolve(base, declared);
|
|
1496
|
+
const inside = path.relative(base, resolved);
|
|
1497
|
+
if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) return resolved;
|
|
1498
|
+
}
|
|
1499
|
+
} catch {
|
|
1500
|
+
// No manifest, or unreadable. `doctor` is the tool that judges a manifest; this one only needs
|
|
1501
|
+
// to know where the policy is, and the default is where it is when nothing says otherwise.
|
|
1502
|
+
}
|
|
1503
|
+
return path.join(workspaceRoot, workspaceDir, "gates.json");
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// ===========================================================================================
|
|
1507
|
+
// Pack-contributed gate fragments — the cascade's middle layer
|
|
1508
|
+
// ===========================================================================================
|
|
1509
|
+
//
|
|
1510
|
+
// The cascade is `core < pack < workspace`, and until now no pack contributed to the gate policy —
|
|
1511
|
+
// ../packs/tools/README.md called that "the cascade's missing middle". The floor was ruled
|
|
1512
|
+
// before any of it was built (../.portulan/proposals/0010-prohibited-as-a-fourth-universal-tier.md,
|
|
1513
|
+
// agreed 2026-07-27): **packs may only tighten.** A pack may raise a tier or add a prohibition; it may
|
|
1514
|
+
// never demote another layer's classification, because a composed-in third-party artifact able to
|
|
1515
|
+
// demote `push` to Auto would be a dependency with the power to disarm the gate containing it — and
|
|
1516
|
+
// the demotion would look exactly like configuration.
|
|
1517
|
+
//
|
|
1518
|
+
// That ruling is why the policy was modelled as a list of id-addressed rules with no dependence on
|
|
1519
|
+
// being the only source. This is the merge step it was shaped for: an addition, not a redesign.
|
|
1520
|
+
|
|
1521
|
+
/** The tier partial order, weakest to strongest. Tightening moves UP it and never down. */
|
|
1522
|
+
const TIER_ORDER = ["auto", "propose", "gated", "prohibited"];
|
|
1523
|
+
|
|
1524
|
+
/** Where a tier sits in the partial order; `-1` for anything that is not a tier. */
|
|
1525
|
+
export const tierRank = (tier) => TIER_ORDER.indexOf(tier);
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* Resolve one declared pack name — the canonical `category/name` — against a list of roots, in order.
|
|
1529
|
+
*
|
|
1530
|
+
* Root-parameterized rather than deriving one location, because the two shapes that matter are not
|
|
1531
|
+
* the same tree: a workspace composing a pack that ships beside it resolves under its own `packs/`,
|
|
1532
|
+
* while an adopter installing from a feed resolves inside the installed plugin. One resolver, told
|
|
1533
|
+
* where to look, so the second case is the same code path rather than a parallel one discovered later.
|
|
1534
|
+
*
|
|
1535
|
+
* Returns `{ name, category, pack, dir, manifest, root }` with `dir` and `root` null when nothing
|
|
1536
|
+
* matched. `root` is which root answered — the fact a caller states per pack once the set is a union.
|
|
1537
|
+
*/
|
|
1538
|
+
export function resolvePack(rawName, roots = []) {
|
|
1539
|
+
// Coerced once, and the COERCED value is what every return carries. This compiler reads
|
|
1540
|
+
// `workspace.json` without validating it — `doctor` is the tool that judges a manifest, and the
|
|
1541
|
+
// two have no ordering between them — so `packs` may hold a number or an object, and the caller
|
|
1542
|
+
// formats what comes back (`.padEnd()`), which on a non-string throws a TypeError that aborts the
|
|
1543
|
+
// whole compile instead of reporting one unresolvable pack. Found by review.
|
|
1544
|
+
const name = String(rawName);
|
|
1545
|
+
const parts = name.split("/");
|
|
1546
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
1547
|
+
return { name, category: null, pack: null, dir: null, manifest: null, root: null, why: "not in `category/name` form" };
|
|
1548
|
+
}
|
|
1549
|
+
const [category, pack] = parts;
|
|
1550
|
+
// A name is a name, never a path: a `..` segment here would resolve a declared pack outside every
|
|
1551
|
+
// root it was supposed to be searched in.
|
|
1552
|
+
if (!SLUG.test(category) || !SLUG.test(pack)) {
|
|
1553
|
+
return { name, category: null, pack: null, dir: null, manifest: null, root: null, why: "`category/name` must both be slugs" };
|
|
1554
|
+
}
|
|
1555
|
+
for (const root of roots) {
|
|
1556
|
+
const dir = path.join(root, category, pack);
|
|
1557
|
+
const manifest = path.join(dir, "pack.json");
|
|
1558
|
+
// `root` rides along because the resolution set may now be a union, and *which* root answered
|
|
1559
|
+
// is the fact a caller states per pack. Deriving it afterwards by re-testing the roots would
|
|
1560
|
+
// be a second implementation of this loop, and the two would drift on the first-match rule.
|
|
1561
|
+
if (fs.existsSync(manifest)) return { name, category, pack, dir, manifest, root, why: null };
|
|
1562
|
+
}
|
|
1563
|
+
return { name, category, pack, dir: null, manifest: null, root: null, why: "no pack.json under any resolution root" };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* The roots a declared pack name is resolved against for a workspace on disk.
|
|
1568
|
+
*
|
|
1569
|
+
* Derived from the manifest's `tree` rather than declared, because adding a key for it would be a
|
|
1570
|
+
* Workspace Definition change nobody decided — and a slot before its consumer is the mistake that
|
|
1571
|
+
* specification was written to avoid. A workspace with no `tree` gets no roots, and its declared packs
|
|
1572
|
+
* are reported unresolvable rather than failed: the same answer `tree`'s absence already gives every
|
|
1573
|
+
* other claim that needs a tree to check.
|
|
1574
|
+
*/
|
|
1575
|
+
export function packRoots(workspaceDir, workspace) {
|
|
1576
|
+
const tree = workspace?.tree;
|
|
1577
|
+
if (typeof tree === "string" && tree.trim()) return [path.resolve(workspaceDir, tree, "packs")];
|
|
1578
|
+
return [];
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/**
|
|
1582
|
+
* The `options` object for `packContributions` when roots were named on the command line.
|
|
1583
|
+
*
|
|
1584
|
+
* **Named roots REPLACE the root derived from `tree`; they are not searched ahead of it.** The first
|
|
1585
|
+
* implementation appended the derived root after the named ones here, while `doctor` and `index` — both
|
|
1586
|
+
* of which spell this as `options.packRoots ?? packRoots(…)` — replaced it. Three tools, two semantics,
|
|
1587
|
+
* and every prose carrier described only one of them. The pre-commit checkpoint on the change that added
|
|
1588
|
+
* the flag found it by attacking it: a workspace with the pack sitting in its own tree, given
|
|
1589
|
+
* `--pack-root <an empty directory>`, compiled **green from the local copy** — which is exactly the thing
|
|
1590
|
+
* the flag exists to exclude, since the whole point is that "the pack resolved from the feed" must not be
|
|
1591
|
+
* satisfiable by a copy lying around. Replacement is the semantics that serves that, so replacement is
|
|
1592
|
+
* what all three now do, and `cli/compile.test.mjs` pins the divergent case rather than trusting the
|
|
1593
|
+
* prose to hold the three in line.
|
|
1594
|
+
*
|
|
1595
|
+
* Returns `{}` when nothing was named, so the derived-only path stays byte-identical to what it was
|
|
1596
|
+
* before the flag existed — a caller that passes no roots must not take a different branch, which is the
|
|
1597
|
+
* property that keeps the flag a *surface* on the existing behaviour rather than a second one.
|
|
1598
|
+
*/
|
|
1599
|
+
// **No caller since `--pack-root auto` landed** — the command line now passes `named` into `rootPlan`
|
|
1600
|
+
// and the precedence rule decides. Kept, exported and documented because four files cite it in prose
|
|
1601
|
+
// for the DEFECT it records rather than for the function it is: three tools, two semantics, found by a
|
|
1602
|
+
// checkpoint. A reader arriving from one of those citations should find this note rather than infer
|
|
1603
|
+
// that the code path is live.
|
|
1604
|
+
export function namedRootsOption(workspaceRoot, namedRoots) {
|
|
1605
|
+
if (!namedRoots?.length) return {};
|
|
1606
|
+
return { packRoots: [...namedRoots] };
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* The one place the three tools agree on where a resolution root comes from.
|
|
1611
|
+
*
|
|
1612
|
+
* `compile`, `doctor` and `index` each spelled the derived-versus-named choice for themselves, and two
|
|
1613
|
+
* of the three spelled it differently — the divergence `namedRootsOption` records, found by a
|
|
1614
|
+
* checkpoint rather than by the suite. Discovery adds a third source and a precedence between all
|
|
1615
|
+
* three, which is three more chances to diverge, so the ordering is written once in `discover.mjs`'s
|
|
1616
|
+
* `resolutionRoots` and **called** by each tool rather than described in three places.
|
|
1617
|
+
*/
|
|
1618
|
+
export function rootPlan(workspaceDir, manifest, { named = [], namedGiven = null, discovery = null, forced = false } = {}) {
|
|
1619
|
+
return resolutionRoots({ named, namedGiven, derived: packRoots(workspaceDir, manifest), discovery, forced });
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* What the packs a workspace declares contribute. Reads each resolved `pack.json` and collects its
|
|
1624
|
+
* gate fragments. It does NOT validate the manifest against the Pack Definition — `doctor` does that,
|
|
1625
|
+
* and this compiler must not depend on `doctor` having been run: the two have no ordering between
|
|
1626
|
+
* them. What it depends on instead is `parse`, which validates every composed rule exactly as it
|
|
1627
|
+
* validates a hand-written one, so a malformed fragment is refused by the same code either way.
|
|
1628
|
+
*/
|
|
1629
|
+
export function packContributions(workspaceRoot, workspaceDir = ".portulan", options = {}) {
|
|
1630
|
+
const base = path.join(workspaceRoot, workspaceDir);
|
|
1631
|
+
let manifest;
|
|
1632
|
+
try {
|
|
1633
|
+
manifest = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"));
|
|
1634
|
+
} catch {
|
|
1635
|
+
// No manifest, or unreadable. `doctor` is the tool that judges a manifest; this one only needs
|
|
1636
|
+
// to know what is composed, and with nothing to go on the answer is nothing.
|
|
1637
|
+
return { contributions: [], unresolved: [] };
|
|
1638
|
+
}
|
|
1639
|
+
const declared = manifest?.packs;
|
|
1640
|
+
if (!Array.isArray(declared) || declared.length === 0) return { contributions: [], unresolved: [] };
|
|
1641
|
+
|
|
1642
|
+
// `options.packRoots` keeps its pre-discovery meaning — the FINAL root set, replacing the derived
|
|
1643
|
+
// one; a caller passing `[]` means the empty set and still gets it. `options.named` is the
|
|
1644
|
+
// command-line shape. **Both now go through the shared rule**, `namedGiven` carrying the
|
|
1645
|
+
// final-set meaning, because the literal plan this replaces was the sixth site of the defect this
|
|
1646
|
+
// change is about: given `packRoots` AND `forced`, it silently ignored the discovery request, and
|
|
1647
|
+
// it returned a plan with no `origins` and no `refusal` — breaking one file over the uniform shape
|
|
1648
|
+
// `resolutionRoots` exists to guarantee. Found by the pre-commit checkpoint's own sibling sweep,
|
|
1649
|
+
// after the same class had already been found once in `index` inside this change.
|
|
1650
|
+
const givenPackRoots = options.packRoots !== undefined && options.packRoots !== null;
|
|
1651
|
+
const plan = rootPlan(base, manifest, {
|
|
1652
|
+
named: givenPackRoots ? [...options.packRoots] : (options.named ?? []),
|
|
1653
|
+
namedGiven: givenPackRoots ? true : null,
|
|
1654
|
+
discovery: options.discovery ?? null,
|
|
1655
|
+
forced: options.forced ?? false,
|
|
1656
|
+
});
|
|
1657
|
+
if (plan.refusal) throw new CompileError(plan.refusal);
|
|
1658
|
+
if (plan.couldNotRun) throw new CompileError(plan.couldNotRun);
|
|
1659
|
+
const roots = plan.roots;
|
|
1660
|
+
const contributions = [];
|
|
1661
|
+
const unresolved = [];
|
|
1662
|
+
for (const name of declared) {
|
|
1663
|
+
const found = resolvePack(name, roots);
|
|
1664
|
+
if (!found.dir) {
|
|
1665
|
+
unresolved.push(found);
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
const packManifest = readJson(found.manifest, `the pack manifest for \`${found.name}\``);
|
|
1669
|
+
const fragments = packManifest?.contributes?.gates;
|
|
1670
|
+
// Checked here rather than left to `composeFragments` iterating it: a manifest is a file a
|
|
1671
|
+
// human edits, `doctor` may not have run (the two tools have no ordering between them), and a
|
|
1672
|
+
// non-array here would surface as a bare TypeError naming neither the pack nor the field.
|
|
1673
|
+
// `?? []` covers absent, not malformed — those are different failures and only one is benign.
|
|
1674
|
+
if (fragments !== undefined && !Array.isArray(fragments)) {
|
|
1675
|
+
throw new CompileError(
|
|
1676
|
+
`the pack manifest for \`${found.name}\` declares \`contributes.gates\` as ` +
|
|
1677
|
+
`${Array.isArray(fragments) ? "an array" : typeof fragments} rather than an array. ` +
|
|
1678
|
+
`Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
contributions.push({ pack: found.name, dir: found.dir, fragments: fragments ?? [] });
|
|
1682
|
+
}
|
|
1683
|
+
// `plan` travels with the result because every consumer prints it: which source won, and the path
|
|
1684
|
+
// it won with, is what tells a feed resolution from a local one.
|
|
1685
|
+
return { contributions, unresolved, plan };
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* Compose pack-contributed fragments onto a workspace policy. **Tighten-only, and it throws.**
|
|
1690
|
+
*
|
|
1691
|
+
* A demotion is refused loudly rather than dropped quietly, because the two are not the same event: a
|
|
1692
|
+
* backend refusing a rule it cannot express is a coverage gap, while a pack moving `gated` to
|
|
1693
|
+
* `propose` is an attempt to disarm a gate, and a build that continues past it has published an
|
|
1694
|
+
* artifact weaker than the policy it claims to compile. Failing closed is right *here* and wrong in
|
|
1695
|
+
* ./gate.mjs for a reason worth keeping straight: this runs at build time against a file you
|
|
1696
|
+
* can edit, while that runs on every tool call and a refusal there makes the session undriveable.
|
|
1697
|
+
*
|
|
1698
|
+
* `auto` is barred by the Pack Definition's tier enum, so a schema-valid pack cannot express a
|
|
1699
|
+
* demotion to unattended at all. It is checked again here anyway — this compiler does not depend on
|
|
1700
|
+
* the schema having been applied, and an unranked tier reaching the comparison would otherwise sort
|
|
1701
|
+
* below everything and read as a tightening of nothing.
|
|
1702
|
+
*/
|
|
1703
|
+
export function composeFragments(policy, contributions) {
|
|
1704
|
+
const rules = [...(policy?.rules ?? [])];
|
|
1705
|
+
const at = new Map(rules.map((rule, i) => [rule?.id, i]));
|
|
1706
|
+
const added = [];
|
|
1707
|
+
const tightened = [];
|
|
1708
|
+
|
|
1709
|
+
for (const { pack, fragments } of contributions) {
|
|
1710
|
+
for (const fragment of fragments ?? []) {
|
|
1711
|
+
const id = fragment?.id;
|
|
1712
|
+
// **The id is validated HERE even though `parse` validates it too, and the duplication is
|
|
1713
|
+
// the point** (#111). Composition deliberately runs BEFORE `parse`, so that a fragment is
|
|
1714
|
+
// graded by exactly the code that grades a hand-written rule — but that ordering means a
|
|
1715
|
+
// malformed id survives composition and surfaces from `parse` with **no pack context at
|
|
1716
|
+
// all**: `rule id undefined is not a slug`, which sends an adopter to audit their own
|
|
1717
|
+
// `gates.json`, the one file that is not at fault. Every other refusal in this loop names
|
|
1718
|
+
// the contributing pack, because naming the dependency is what the merge step is for.
|
|
1719
|
+
//
|
|
1720
|
+
// It fails closed either way — `parse` refuses the composed policy and nothing invalid
|
|
1721
|
+
// compiles — so this is a diagnostic repair, not a correctness one, and it is written down
|
|
1722
|
+
// as such.
|
|
1723
|
+
//
|
|
1724
|
+
// **It also closes a path the issue did not name.** `at` is keyed by `rule?.id`, so with no
|
|
1725
|
+
// check here a SECOND id-less fragment finds `at.has(undefined)` true and is composed onto
|
|
1726
|
+
// the first one — two unrelated fragments from two packs merged because they were equally
|
|
1727
|
+
// malformed, reported as a tightening of a rule that does not exist.
|
|
1728
|
+
if (typeof id !== "string" || !SLUG.test(id)) {
|
|
1729
|
+
throw new CompileError(
|
|
1730
|
+
`pack \`${pack}\` contributes a fragment whose id is ${JSON.stringify(id)}, which is not a slug. ` +
|
|
1731
|
+
"Ids are referenced from prose and must be greppable, and a fragment without one cannot be " +
|
|
1732
|
+
"matched against the policy it means to tighten. Fix the pack, not the workspace's own gate policy.",
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
const rank = tierRank(fragment?.tier);
|
|
1736
|
+
if (rank < 0) {
|
|
1737
|
+
throw new CompileError(
|
|
1738
|
+
`pack \`${pack}\` contributes fragment \`${id}\` with tier ${JSON.stringify(fragment?.tier)}, ` +
|
|
1739
|
+
`which is not one of ${TIER_ORDER.join(" / ")}. An unrecognised tier is not a fragment to skip.`,
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
if (fragment.tier === "auto") {
|
|
1743
|
+
throw new CompileError(
|
|
1744
|
+
`pack \`${pack}\` contributes fragment \`${id}\` at tier \`auto\`. A pack may only ADD ` +
|
|
1745
|
+
`restriction, and \`auto\` is the absence of it — the Pack Definition leaves \`auto\` out ` +
|
|
1746
|
+
`of the tier enum for this reason.`,
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1749
|
+
if (!at.has(id)) {
|
|
1750
|
+
at.set(id, rules.length);
|
|
1751
|
+
rules.push(fragment);
|
|
1752
|
+
added.push({ pack, id, tier: fragment.tier });
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
const base = rules[at.get(id)];
|
|
1756
|
+
const baseRank = tierRank(base?.tier);
|
|
1757
|
+
// The base's tier must BE a tier before any comparison means anything. Without this,
|
|
1758
|
+
// `tierRank` returns -1 for a malformed base and every fragment outranks it, so the
|
|
1759
|
+
// refusal below never fires and the fragment REPLACES the malformed rule — a policy
|
|
1760
|
+
// `parse` refuses on its own compiles green once a pack happens to name the same id.
|
|
1761
|
+
// Measured: a base rule at tier `bogus` was refused by `parse` alone, and accepted after
|
|
1762
|
+
// composition, reported as `tightens bogus → gated`. That is a fail-open in gate
|
|
1763
|
+
// machinery healed by a dependency, and the printed provenance claims a tightening from
|
|
1764
|
+
// a tier that is not one. Found by review, in the suppressed channel.
|
|
1765
|
+
if (baseRank < 0) {
|
|
1766
|
+
throw new CompileError(
|
|
1767
|
+
`pack \`${pack}\` contributes a fragment for \`${id}\`, but the policy's own rule \`${id}\` ` +
|
|
1768
|
+
`declares tier ${JSON.stringify(base?.tier)}, which is not one of ${TIER_ORDER.join(" / ")}. ` +
|
|
1769
|
+
`Refusing to compose onto a rule whose tier cannot be compared — fix the workspace's gate ` +
|
|
1770
|
+
`policy first. A pack must never be able to make an invalid policy compile.`,
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
// A tier is not the whole rule. Raising the tier while REPLACING the action removes the
|
|
1774
|
+
// matcher and passes every rank comparison — measured on this repository's own policy: a
|
|
1775
|
+
// fragment `{id: force-push-without-a-lease, tier: prohibited, action: {none: …}}` was
|
|
1776
|
+
// reported as `tightens gated → prohibited` and the emitted `Bash(git push --force:*)`
|
|
1777
|
+
// gate DISAPPEARED, leaving the workspace strictly less cautious about the exact action
|
|
1778
|
+
// the rule exists to gate. Ids are greppable by design and ship in core, so knowing one
|
|
1779
|
+
// is not a barrier. A pack may raise a rule's tier; it may not redefine what the rule
|
|
1780
|
+
// matches. Found by the pre-commit supervisor, which is the checkpoint's whole argument.
|
|
1781
|
+
if (rank <= baseRank) {
|
|
1782
|
+
throw new CompileError(
|
|
1783
|
+
`pack \`${pack}\` would move rule \`${id}\` from \`${base?.tier}\` to ` +
|
|
1784
|
+
`\`${fragment.tier}\`, which does not tighten it. Packs may only tighten: a pack may ` +
|
|
1785
|
+
`raise a tier or add a prohibition, never demote another layer's classification ` +
|
|
1786
|
+
`(../.portulan/proposals/0010-prohibited-as-a-fourth-universal-tier.md). The workspace ` +
|
|
1787
|
+
`owns its own policy and may still set this tier in its own gate map.`,
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
const shape = (rule) => {
|
|
1791
|
+
const action = rule?.action;
|
|
1792
|
+
if (!action || typeof action !== "object" || Array.isArray(action)) return null;
|
|
1793
|
+
const kinds = Object.keys(action);
|
|
1794
|
+
return kinds.length === 1 ? `${kinds[0]}:${action[kinds[0]]}` : null;
|
|
1795
|
+
};
|
|
1796
|
+
const here = shape(fragment);
|
|
1797
|
+
const there = shape(base);
|
|
1798
|
+
if (here === null || there === null || here !== there) {
|
|
1799
|
+
throw new CompileError(
|
|
1800
|
+
`pack \`${pack}\` would tighten rule \`${id}\` to \`${fragment.tier}\` while CHANGING what it ` +
|
|
1801
|
+
`matches (${there ?? "unreadable"} → ${here ?? "unreadable"}). A pack may raise a rule's ` +
|
|
1802
|
+
`tier; it may not redefine the action, because replacing the matcher removes the gate ` +
|
|
1803
|
+
`while every tier comparison still reads as a tightening. To gate a different action, ` +
|
|
1804
|
+
`contribute a NEW id; to change what an existing rule matches, edit the workspace's own ` +
|
|
1805
|
+
`gate map, which owns its policy.`,
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
rules[at.get(id)] = fragment;
|
|
1809
|
+
tightened.push({ pack, id, from: base.tier, to: fragment.tier });
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
return { policy: { ...policy, rules }, added, tightened };
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
function readJson(file, what) {
|
|
1816
|
+
let raw;
|
|
1817
|
+
try {
|
|
1818
|
+
raw = fs.readFileSync(file, "utf8");
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
throw new CompileError(`cannot read ${what} at ${file} — ${error.code ?? error.message}`);
|
|
1821
|
+
}
|
|
1822
|
+
try {
|
|
1823
|
+
return JSON.parse(raw);
|
|
1824
|
+
} catch (error) {
|
|
1825
|
+
throw new CompileError(`${what} at ${file} is not valid JSON — ${error.message}`);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/** The emitted artifact, as text. One writer, so `--check` compares what `--write` would produce. */
|
|
1830
|
+
export function render(settings) {
|
|
1831
|
+
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/**
|
|
1835
|
+
* Print the per-host backend matrix.
|
|
1836
|
+
*
|
|
1837
|
+
* The criterion asks for the floor backend to be "positioned in the matrix as the floor backend:
|
|
1838
|
+
* what every host falls back to, and all that a host with no hook system has" — so the matrix says
|
|
1839
|
+
* that in words rather than leaving a reader to infer it from a column heading.
|
|
1840
|
+
*/
|
|
1841
|
+
function printMatrix(say, parsed, columns, { source }) {
|
|
1842
|
+
say(`gate policy: ${source} — ${parsed.rules.length} rule(s), ${columns.length} backend(s)`);
|
|
1843
|
+
say();
|
|
1844
|
+
for (const column of columns) {
|
|
1845
|
+
say(` ${column.label.padEnd(28)} ${column.compiled.length} compiled, ${column.refused.length} refused` +
|
|
1846
|
+
(column.artifact ? ` → ${column.artifact.path}` : " → no artifact"));
|
|
1847
|
+
}
|
|
1848
|
+
say();
|
|
1849
|
+
say(" The GitHub repository ruleset is the FLOOR backend: what every host falls back to, and all");
|
|
1850
|
+
say(" that a host with no hook system has. It holds when everything above it fails, and it is the");
|
|
1851
|
+
say(" only layer here indifferent to how a command was spelled.");
|
|
1852
|
+
say();
|
|
1853
|
+
|
|
1854
|
+
const rows = matrix(parsed, { source });
|
|
1855
|
+
const width = Math.max(4, ...rows.map((r) => r.id.length));
|
|
1856
|
+
say(` ${"rule".padEnd(width)} ${columns.map((c) => c.backend.padEnd(16)).join(" ")}`);
|
|
1857
|
+
for (const row of rows) {
|
|
1858
|
+
say(` ${row.id.padEnd(width)} ${columns.map((c) => row.backends[c.backend].verdict.padEnd(16)).join(" ")}`);
|
|
1859
|
+
}
|
|
1860
|
+
say();
|
|
1861
|
+
|
|
1862
|
+
// The honest degradation signal, and the reason the matrix is worth printing at all: not which
|
|
1863
|
+
// backend is fuller, but what this policy declares that NOTHING enforces.
|
|
1864
|
+
//
|
|
1865
|
+
// Split by tier, because the two halves mean opposite things and printing them together would
|
|
1866
|
+
// pad the alarming number with a harmless one. An `auto` rule compiled by no backend is the
|
|
1867
|
+
// system working — unattended is what that tier means. A `gated` or `prohibited` rule compiled
|
|
1868
|
+
// by no backend is a gate that exists only as a sentence, which is the thing this product is
|
|
1869
|
+
// against. Reporting eight where three are real is how a report gets skimmed.
|
|
1870
|
+
const uncovered = rows.filter((r) => columns.every((c) => r.backends[c.backend].verdict === "refused"));
|
|
1871
|
+
const gaps = uncovered.filter((r) => r.tier === "gated" || r.tier === "prohibited");
|
|
1872
|
+
const unattended = uncovered.filter((r) => r.tier === "auto");
|
|
1873
|
+
|
|
1874
|
+
if (gaps.length === 0) {
|
|
1875
|
+
say(" Every gate in this policy is compiled by at least one backend.");
|
|
1876
|
+
} else {
|
|
1877
|
+
say(` ${gaps.length} GATE(S) no backend compiles — declared here and enforced by nothing but a habit:`);
|
|
1878
|
+
for (const row of gaps) say(` ${row.id.padEnd(width)} ${row.tier}`);
|
|
1879
|
+
}
|
|
1880
|
+
if (unattended.length) {
|
|
1881
|
+
say(` (${unattended.length} \`auto\` rule(s) are compiled by no backend, which is what that tier means.)`);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* What `--workspace <dir>` names, and where the workspace inside it lives.
|
|
1887
|
+
*
|
|
1888
|
+
* Two shapes, told apart by **`tree`** — the one thing proposal 0017 says is keyed to location, and the
|
|
1889
|
+
* same key `cli/vendor.mjs` reads to decide a residence:
|
|
1890
|
+
*
|
|
1891
|
+
* - `<dir>` is a **repository root** — no manifest here, so the workspace is at `.portulan` and this is
|
|
1892
|
+
* byte-identical to what this tool did before the distinction existed.
|
|
1893
|
+
* - `<dir>` is a **workspace directory** — it holds `workspace.json`. A manifest declaring `tree`
|
|
1894
|
+
* resolves back to the repository it governs; one declaring none is feed-side, and *is* its own root,
|
|
1895
|
+
* because an installed plugin's directory is the workspace root.
|
|
1896
|
+
*
|
|
1897
|
+
* Nothing here validates the manifest. `doctor` judges manifests and these two tools have no ordering
|
|
1898
|
+
* between them — this one only needs to know where the policy is.
|
|
1899
|
+
*
|
|
1900
|
+
* **`workspaceRoot` is returned as given on the repository-root branch and resolved on the other two,
|
|
1901
|
+
* and that asymmetry is deliberate rather than an oversight.** The repository-root branch is the path
|
|
1902
|
+
* every existing caller takes, including `.portulan/verify/compile.sh`'s `--workspace .`; returning the
|
|
1903
|
+
* argument verbatim is what keeps this change byte-identical for them, output included — `compile` joins
|
|
1904
|
+
* this value into the paths it PRINTS, so resolving it here would turn `.claude/settings.json` into an
|
|
1905
|
+
* absolute path in every run, for no defect. The other two branches derive a path rather than echoing
|
|
1906
|
+
* one, and a derived path has to be absolute to mean anything. Raised as an inconsistency by Copilot
|
|
1907
|
+
* (round 10 on #164) and declined on that ground; the contract is stated here rather than changed.
|
|
1908
|
+
*/
|
|
1909
|
+
export function resolveWorkspace(named) {
|
|
1910
|
+
const dir = path.resolve(named);
|
|
1911
|
+
const manifestPath = path.join(dir, "workspace.json");
|
|
1912
|
+
let raw;
|
|
1913
|
+
try {
|
|
1914
|
+
raw = fs.readFileSync(manifestPath, "utf8");
|
|
1915
|
+
} catch (cause) {
|
|
1916
|
+
// **Only `ENOENT` means "this is not a workspace directory".** Anything else — `EACCES` above
|
|
1917
|
+
// all — means the question could not be answered, and falling back to `.portulan` would answer
|
|
1918
|
+
// it *no* and then fail somewhere else with a confusing secondary error about a policy file.
|
|
1919
|
+
// The first cut of this function caught everything, which is the fail-open — "nothing looked"
|
|
1920
|
+
// reported as "nothing wrong" — committed in a change whose own header states the rule three times.
|
|
1921
|
+
// Copilot, round 1 on #164.
|
|
1922
|
+
if (cause.code === "ENOENT") return { workspaceRoot: named, workspaceDir: ".portulan" };
|
|
1923
|
+
throw new CompileError(
|
|
1924
|
+
`${manifestPath} could not be read — ${cause.code ?? cause.message}. Only a MISSING manifest means ` +
|
|
1925
|
+
`\`${named}\` is a repository root; refusing to assume \`.portulan\` on a question nothing could answer`,
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1928
|
+
let manifest;
|
|
1929
|
+
try {
|
|
1930
|
+
manifest = JSON.parse(raw);
|
|
1931
|
+
} catch (cause) {
|
|
1932
|
+
throw new CompileError(
|
|
1933
|
+
`${manifestPath} is not valid JSON — ${cause.message}. A manifest is present and unreadable, which is ` +
|
|
1934
|
+
`not the same as absent: treating it as absent would compile a policy from somewhere this workspace never named`,
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
if (typeof manifest?.tree === "string" && manifest.tree.trim()) {
|
|
1938
|
+
const root = path.resolve(dir, manifest.tree);
|
|
1939
|
+
const inside = path.relative(root, dir);
|
|
1940
|
+
// A `tree` that does not contain its own workspace is a manifest `doctor` refuses; here it just
|
|
1941
|
+
// means the derivation cannot be trusted, so the safe answer is the one that changes nothing.
|
|
1942
|
+
if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) {
|
|
1943
|
+
return { workspaceRoot: root, workspaceDir: inside.split(path.sep).join("/") };
|
|
1944
|
+
}
|
|
1945
|
+
return { workspaceRoot: named, workspaceDir: ".portulan" };
|
|
1946
|
+
}
|
|
1947
|
+
return { workspaceRoot: dir, workspaceDir: "." };
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* The help screen — see `./doctor.mjs`'s for the contract and why these three gained one late.
|
|
1952
|
+
* `../.portulan/dod.md` condition 4 binds it: every flag below exists in the parser above.
|
|
1953
|
+
*/
|
|
1954
|
+
function usage() {
|
|
1955
|
+
return [
|
|
1956
|
+
"portulan compile — compile a workspace's gate policy into host enforcement",
|
|
1957
|
+
"",
|
|
1958
|
+
" portulan compile [--check] [--matrix] [--workspace <dir>] [--pack-root <dir>|auto]...",
|
|
1959
|
+
"",
|
|
1960
|
+
" (no flag) compile the policy and write each backend's artifact",
|
|
1961
|
+
" --check write nothing; exit 1 if an artifact is out of date against the policy",
|
|
1962
|
+
" --matrix print every rule against every backend, and the gates neither compiles",
|
|
1963
|
+
" --workspace the workspace directory to compile; defaults to `.portulan`",
|
|
1964
|
+
" --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
|
|
1965
|
+
" A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
|
|
1966
|
+
"",
|
|
1967
|
+
"The compiler emits RESTRICTION only: `auto` and `propose` compile to nothing in the Claude Code",
|
|
1968
|
+
"backend by design, and the partition inverts in the repository ruleset, which is the floor.",
|
|
1969
|
+
"",
|
|
1970
|
+
"Exit codes: 0 succeeded · 1 a red verdict · 2 could not run.",
|
|
1971
|
+
].join("\n");
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
export function run(argv, options = {}) {
|
|
1975
|
+
const say = (line = "") => {
|
|
1976
|
+
if (!options.quiet) process.stdout.write(`${line}\n`);
|
|
1977
|
+
};
|
|
1978
|
+
// **Before every other argument decision**, so asking for help cannot be outranked by a
|
|
1979
|
+
// complaint about the rest of the command line. `./portulan.mjs` states the contract: an
|
|
1980
|
+
// explicit `--help` exits 0, because asking for help is a request and it succeeded.
|
|
1981
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
1982
|
+
say(usage());
|
|
1983
|
+
return 0;
|
|
1984
|
+
}
|
|
1985
|
+
try {
|
|
1986
|
+
let named = process.cwd();
|
|
1987
|
+
let check = false;
|
|
1988
|
+
let showMatrix = false;
|
|
1989
|
+
// Named pack roots, which REPLACE the root derived from `tree` rather than being searched ahead
|
|
1990
|
+
// of it — see `namedRootsOption` for why that distinction cost a checkpoint finding.
|
|
1991
|
+
// `packContributions` has taken `options.packRoots` since session 0 — shaped so that an adopter
|
|
1992
|
+
// resolving from an installed feed travels the same code path as a workspace whose packs ship
|
|
1993
|
+
// beside it — and nothing ever set it, so the parameter was reachable only from a test.
|
|
1994
|
+
// Discovery of a host's plugin cache landed at milestone 7 for BOTH halves (#123): ./discover.mjs
|
|
1995
|
+
// dereferences a POINTER's `governed_by`, and `--pack-root auto` resolves a pack root from the same
|
|
1996
|
+
// record.
|
|
1997
|
+
//
|
|
1998
|
+
// **Discovery IS the default as of 2026-08-13**, and this comment said the opposite — *"what is
|
|
1999
|
+
// deliberately NOT taken is a DEFAULT … defaulting it would change what every existing run
|
|
2000
|
+
// resolves against"*. It would, and the maintainer ruled the change: row 7's clause makes
|
|
2001
|
+
// `--pack-root` *optional where discovery finds a root*, and it was not. Both of the old
|
|
2002
|
+
// sentence's grounds survive where they were actually load-bearing — a **named** root still
|
|
2003
|
+
// REPLACES the derived one (#117, untouched, the arm above), and a required check's verdict still
|
|
2004
|
+
// cannot move with the machine, because `../.portulan/verify/compile.sh` **names its root** and
|
|
2005
|
+
// `./pinned-roots.live.test.mjs` reds if it stops. What changed is which carrier holds that
|
|
2006
|
+
// second guarantee: the pin, not the absence of a default.
|
|
2007
|
+
const namedRoots = [];
|
|
2008
|
+
let forced = false;
|
|
2009
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
2010
|
+
if (argv[i] === "--check") check = true;
|
|
2011
|
+
else if (argv[i] === "--matrix") showMatrix = true;
|
|
2012
|
+
else if (argv[i] === "--workspace") {
|
|
2013
|
+
named = argv[i + 1];
|
|
2014
|
+
i += 1;
|
|
2015
|
+
if (named === undefined) throw new CompileError("--workspace needs a directory");
|
|
2016
|
+
} else if (argv[i] === "--pack-root") {
|
|
2017
|
+
const root = argv[i + 1];
|
|
2018
|
+
i += 1;
|
|
2019
|
+
// A SINGLE leading `-` is refused too, not just `--`: `--pack-root -h` would otherwise
|
|
2020
|
+
// consume the flag as a path and fail later as an unreadable one. `doctor` already read
|
|
2021
|
+
// it this way, so this aligns the three rather than inventing a rule. A directory whose
|
|
2022
|
+
// name really begins with `-` is `./-name`, the same escape `auto` takes.
|
|
2023
|
+
if (root === undefined || root.startsWith("-"))
|
|
2024
|
+
throw new CompileError(
|
|
2025
|
+
"--pack-root needs a directory, or `auto` to discover one from the host plugin cache. " +
|
|
2026
|
+
"A directory actually named `auto` is `./auto`",
|
|
2027
|
+
);
|
|
2028
|
+
// The keyword, matched on the RAW argument before `path.resolve`, so a directory
|
|
2029
|
+
// genuinely named `auto` is still reachable as `./auto`. See ./discover.mjs.
|
|
2030
|
+
if (root === AUTO) {
|
|
2031
|
+
forced = true;
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
// A root that is not there is a fact about the filesystem, not a pack that failed to
|
|
2035
|
+
// resolve — and reporting it as the latter sends an author to the one file that is not
|
|
2036
|
+
// at fault. Same distinction as ../.portulan/memory/verify-preconditions-fail-closed.md.
|
|
2037
|
+
//
|
|
2038
|
+
// A FILE is the sharper case here than in the other two tools, and the reason is what this
|
|
2039
|
+
// one emits: a file-valued root made resolution fail and produced a **misleading green
|
|
2040
|
+
// compile** that had simply ignored the intended root. A green is what a session acts on,
|
|
2041
|
+
// so it is worse than the exit 2 the argument deserves. Third carrier of one rule, found by
|
|
2042
|
+
// Copilot on #117, round 7 — the sibling class the 2026-07-27 ruling names.
|
|
2043
|
+
let rootStat = null;
|
|
2044
|
+
try {
|
|
2045
|
+
rootStat = fs.statSync(root);
|
|
2046
|
+
} catch (cause) {
|
|
2047
|
+
throw new CompileError(
|
|
2048
|
+
`--pack-root ${root} cannot be read — ${cause.code ?? cause.message}. Refusing to report a pack unresolvable against a root nothing looked in`,
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
if (!rootStat.isDirectory()) {
|
|
2052
|
+
throw new CompileError(
|
|
2053
|
+
`--pack-root ${root} is not a directory — a resolution root is a directory packs are looked up under`,
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
namedRoots.push(path.resolve(root));
|
|
2057
|
+
} else throw new CompileError(`unknown argument ${JSON.stringify(argv[i])}`);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// Refused HERE — the last statement of the argument parse, before a workspace is resolved or a
|
|
2061
|
+
// policy read. It sat below both until Copilot's round 2, where an unrelated workspace or
|
|
2062
|
+
// policy error masked the refusal and this tool disagreed with the four beside it about *when*
|
|
2063
|
+
// the answer is given. The other tools' tests pin exactly that property; this one's did not,
|
|
2064
|
+
// which is why the placement could drift here and nowhere else.
|
|
2065
|
+
const bothAsked = namedWithAuto(namedRoots, forced);
|
|
2066
|
+
if (bothAsked) throw new CompileError(bothAsked);
|
|
2067
|
+
|
|
2068
|
+
// The workspace may be named as a repository root or as the workspace directory itself, and the
|
|
2069
|
+
// second is how a feed-side workspace is reachable at all — see `resolveWorkspace`.
|
|
2070
|
+
const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
|
|
2071
|
+
const policyFile = policyPath(workspaceRoot, workspaceDir);
|
|
2072
|
+
const policy = readJson(policyFile, "the gate policy");
|
|
2073
|
+
// The cascade's middle layer, composed before the policy is parsed so that a pack's fragment
|
|
2074
|
+
// is validated by exactly the code that validates a hand-written rule.
|
|
2075
|
+
// The thunk means the host's plugin record is read only on a path where it can win — which is
|
|
2076
|
+
// still true of the **named** and **refused** arms and is no longer what keeps `compile --check`
|
|
2077
|
+
// host-independent. That sentence read *"a required check that names no root"*, and since
|
|
2078
|
+
// 2026-08-13 `../.portulan/verify/compile.sh` runs `--pack-root packs --check`: it names one, and
|
|
2079
|
+
// a named root replaces every other source. The pin is the carrier; the thunk is what keeps an
|
|
2080
|
+
// API caller wiring none hermetic.
|
|
2081
|
+
const { contributions, unresolved, plan } = packContributions(workspaceRoot, workspaceDir, {
|
|
2082
|
+
named: namedRoots,
|
|
2083
|
+
discovery: () => discoverPackRoots(),
|
|
2084
|
+
forced,
|
|
2085
|
+
});
|
|
2086
|
+
// Under `--matrix` only, EXCEPT when the set is a union: that line moves with what is
|
|
2087
|
+
// installed, which is why it is normally withheld from a byte-compared run — but a union is the
|
|
2088
|
+
// one arrangement where a tree-derived root joined the search without anyone naming it, and the
|
|
2089
|
+
// whole contract of the union is that this is never silent.
|
|
2090
|
+
//
|
|
2091
|
+
// **`--check` CAN reach it now, and that is the point rather than a leak.** This read *"`--check`
|
|
2092
|
+
// cannot reach it: it names no root and passes no `auto`, so `forced` is false and the plan is
|
|
2093
|
+
// never a union there"* — all three clauses went false on 2026-08-13, when the unasked arm began
|
|
2094
|
+
// consulting discovery. A `--check` run whose resolution set included a discovered root and said
|
|
2095
|
+
// nothing would be the silent substitution `--pack-root auto`'s own justification refuses, so the
|
|
2096
|
+
// line prints. The **required** check reaches neither, because it names a root: `source` is
|
|
2097
|
+
// `named` there and this condition is false for the right reason.
|
|
2098
|
+
if (plan && (showMatrix || plan.source === "union")) say(`packs: resolution root ${plan.source} — ${plan.why}`);
|
|
2099
|
+
const composed = composeFragments(policy, contributions);
|
|
2100
|
+
const parsed = parse(composed.policy);
|
|
2101
|
+
const source = path.relative(workspaceRoot, policyFile).split(path.sep).join("/");
|
|
2102
|
+
// `root` is what makes the emitted runner path correct for the workspace being compiled rather
|
|
2103
|
+
// than for the one this process happens to sit in. The plumbing existed and **the caller never
|
|
2104
|
+
// used it**: `compile --workspace <other-project>` wrote that project's settings naming
|
|
2105
|
+
// `${CLAUDE_PROJECT_DIR}/cli/stop-gate.mjs`, a file the target does not have — and a missing hook
|
|
2106
|
+
// fails open, silently. That is the exact defect class this whole change exists to close,
|
|
2107
|
+
// reintroduced through the cross-compile path. Found by the pre-commit checkpoint, which
|
|
2108
|
+
// demonstrated it rather than reasoning about it.
|
|
2109
|
+
const columns = backends(parsed, { source, root: path.resolve(workspaceRoot), workspaceDir });
|
|
2110
|
+
|
|
2111
|
+
// Printed before the backends, because a rule's provenance changes how its compiled line reads
|
|
2112
|
+
// — and an unresolvable pack is a declaration the workspace believes it composed.
|
|
2113
|
+
for (const a of composed.added) say(`pack ${a.pack.padEnd(30)} adds \`${a.id}\` (${a.tier})`);
|
|
2114
|
+
for (const t of composed.tightened) {
|
|
2115
|
+
say(`pack ${t.pack.padEnd(30)} tightens \`${t.id}\` ${t.from} → ${t.to}`);
|
|
2116
|
+
}
|
|
2117
|
+
for (const u of unresolved) {
|
|
2118
|
+
say(`pack ${u.name.padEnd(30)} UNRESOLVED — ${u.why}; it contributes nothing`);
|
|
2119
|
+
}
|
|
2120
|
+
if (composed.added.length || composed.tightened.length || unresolved.length) say();
|
|
2121
|
+
|
|
2122
|
+
if (showMatrix) {
|
|
2123
|
+
printMatrix(say, parsed, columns, { source });
|
|
2124
|
+
return 0;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
for (const column of columns) {
|
|
2128
|
+
say(`${column.label}: ${column.compiled.length} compiled, ${column.refused.length} refused`);
|
|
2129
|
+
for (const gate of column.compiled) say(` gate ${gate.id.padEnd(38)} ${gate.surface}`);
|
|
2130
|
+
// Refusals are printed, always. A silent cap reads as "covered everything" when it did
|
|
2131
|
+
// not — and these are the rows the per-host degradation report is built from.
|
|
2132
|
+
for (const r of column.refused) say(` refused ${r.id.padEnd(38)} ${r.why}`);
|
|
2133
|
+
for (const n of column.notes) say(` note ${n}`);
|
|
2134
|
+
say();
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
if (check) {
|
|
2138
|
+
let drifted = 0;
|
|
2139
|
+
for (const column of columns) {
|
|
2140
|
+
// A backend with no artifact is owed no file — but *present* and not owed is drift,
|
|
2141
|
+
// and it was the hole here for one checkpoint. Deleting `floor` from a policy left
|
|
2142
|
+
// the ruleset behind: importable, valid, and claiming in its own `name` field to be
|
|
2143
|
+
// generated from a policy that no longer produces it. `--check` reported GREEN,
|
|
2144
|
+
// having compared nothing, which is the shape this recipe exists against. The
|
|
2145
|
+
// eighth fail-open of this repository's series, and again in scaffolding rather than
|
|
2146
|
+
// in a check. Found at the pre-commit checkpoint.
|
|
2147
|
+
if (!column.artifact) {
|
|
2148
|
+
const orphan = path.join(workspaceRoot, ...artifactPaths(workspaceDir)[column.backend].split("/"));
|
|
2149
|
+
if (fs.existsSync(orphan)) {
|
|
2150
|
+
say(`RED — ${orphan} exists and ${policyFile} no longer compiles to it. Recompile to remove it.`);
|
|
2151
|
+
drifted += 1;
|
|
2152
|
+
}
|
|
2153
|
+
continue;
|
|
2154
|
+
}
|
|
2155
|
+
const file = path.join(workspaceRoot, ...column.artifact.path.split("/"));
|
|
2156
|
+
let current = null;
|
|
2157
|
+
try {
|
|
2158
|
+
current = fs.readFileSync(file, "utf8");
|
|
2159
|
+
} catch {
|
|
2160
|
+
say(`RED — ${file} does not exist; the policy declares enforcement that nothing carries`);
|
|
2161
|
+
drifted += 1;
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
if (current !== column.artifact.text) {
|
|
2165
|
+
say(`RED — ${file} has drifted from ${policyFile}. Recompile.`);
|
|
2166
|
+
drifted += 1;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
if (drifted) return 1;
|
|
2170
|
+
say("GREEN — every emitted artifact matches the policy");
|
|
2171
|
+
return 0;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
for (const column of columns) {
|
|
2175
|
+
if (!column.artifact) {
|
|
2176
|
+
// Remove a stale artifact rather than leaving it for `--check` to complain about
|
|
2177
|
+
// forever: the RED above tells a reader to recompile, so recompiling has to be what
|
|
2178
|
+
// fixes it. Deleting a generated file this compiler wrote is the one deletion it may
|
|
2179
|
+
// do — it is reproducible by definition, and it is already in git if it mattered.
|
|
2180
|
+
const orphan = path.join(workspaceRoot, ...artifactPaths(workspaceDir)[column.backend].split("/"));
|
|
2181
|
+
if (fs.existsSync(orphan)) {
|
|
2182
|
+
fs.rmSync(orphan);
|
|
2183
|
+
say(`removed ${orphan} — the policy no longer compiles to it`);
|
|
2184
|
+
}
|
|
2185
|
+
say(`${column.label}: nothing to write — ${column.refused.length} rule(s) refused, listed above`);
|
|
2186
|
+
continue;
|
|
2187
|
+
}
|
|
2188
|
+
const file = path.join(workspaceRoot, ...column.artifact.path.split("/"));
|
|
2189
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2190
|
+
fs.writeFileSync(file, column.artifact.text);
|
|
2191
|
+
say(`wrote ${file}`);
|
|
2192
|
+
}
|
|
2193
|
+
return 0;
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
// Anything reaching here means compile could not judge — including a defect in compile
|
|
2196
|
+
// itself. Exit 2. Exit 1 would assert that an artifact drifted when nothing was compared,
|
|
2197
|
+
// and exit 0 would be the fail-open this tool exists against.
|
|
2198
|
+
if (!options.quiet) {
|
|
2199
|
+
process.stderr.write(
|
|
2200
|
+
`compile: ${error instanceof CompileError ? error.message : `unanticipated failure — ${error.stack ?? error}`}\n`,
|
|
2201
|
+
);
|
|
2202
|
+
}
|
|
2203
|
+
return 2;
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
2208
|
+
process.exitCode = run(process.argv.slice(2));
|
|
2209
|
+
}
|