@sleepy_panda_srl/portulan 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli/README.md +69 -2
- package/cli/compile.mjs +555 -37
- package/cli/doctor.mjs +5 -1
- package/cli/drills.mjs +1541 -0
- package/cli/fuzz-shell.mjs +880 -0
- package/cli/gate.mjs +3 -2
- package/cli/goldens.mjs +469 -0
- package/cli/init.mjs +4 -1
- package/cli/mutants.mjs +925 -0
- package/cli/pack-identity.mjs +9 -2
- package/cli/pack-version.mjs +7 -0
- package/cli/recipe-set.mjs +6 -0
- package/cli/release-eval.mjs +1068 -0
- package/cli/review-meter.mjs +741 -0
- package/cli/skill-goldens.mjs +590 -0
- package/cli/stop-gate.mjs +107 -4
- package/cli/telemetry.mjs +1047 -0
- package/cli/version-carriers.mjs +13 -0
- package/core/templates/gate-policy.md +27 -8
- package/evals/README.md +910 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/README.md +32 -0
- package/package.json +7 -2
- package/packs/rituals/checkpoints/README.md +8 -1
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/spec/slots.md +26 -2
package/cli/compile.mjs
CHANGED
|
@@ -70,7 +70,11 @@ export class CompileError extends Error {
|
|
|
70
70
|
// Workspace Definition. A fresh reviewer at milestone 6 session 1 sight-read this as a carrier left
|
|
71
71
|
// stale by the 2.5 → 2.6 bump and had to measure to clear it. It is correctly untouched by any
|
|
72
72
|
// Workspace Definition bump; renamed to say so, because the next reader may not measure.
|
|
73
|
-
|
|
73
|
+
// Exported so a test can assert MEMBERSHIP against it rather than restate the literal. The
|
|
74
|
+
// scaffold `new gate-policy` emits must declare a spec THIS set contains (#329), and a rail
|
|
75
|
+
// spelling `"2.2"` on its own side would pass while the two drifted apart — which is the
|
|
76
|
+
// shape #329 was.
|
|
77
|
+
export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2"]);
|
|
74
78
|
|
|
75
79
|
const TIERS = new Set(["auto", "propose", "gated", "prohibited"]);
|
|
76
80
|
|
|
@@ -81,8 +85,12 @@ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
|
81
85
|
const FILE_PATH = /^[^#?:/]([^#?:]*[^#?:/])?$/;
|
|
82
86
|
|
|
83
87
|
/** The tools that can write a path, and the tools that can read one. */
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
// Exported since 2026-08-24 so `./goldens.mjs` can DERIVE which branch of `matchesRule` a fixture
|
|
89
|
+
// exercises instead of a corpus author declaring it from memory. A hand-declared path would be a
|
|
90
|
+
// second carrier of what these two tables already decide, and the fixture format asks for the path on
|
|
91
|
+
// every case — which is exactly the volume at which a hand-copied answer goes wrong.
|
|
92
|
+
export const WRITE_TOOLS = ["Edit", "Write", "NotebookEdit"];
|
|
93
|
+
export const READ_TOOLS = ["Read"];
|
|
86
94
|
|
|
87
95
|
// ===========================================================================================
|
|
88
96
|
// 1. Parse: policy -> validated, normalised rules. NO backend opinion lives here.
|
|
@@ -474,7 +482,10 @@ function stripHeredocs(command) {
|
|
|
474
482
|
}
|
|
475
483
|
|
|
476
484
|
/** A shell command split into words, with unquoted operators kept as words of their own. */
|
|
477
|
-
|
|
485
|
+
// Exported since 2026-08-24 so the suite can hold `REDIRECTION_TARGET` to THIS definition of a word
|
|
486
|
+
// rather than to a reviewer's patience. Three review findings in a row were the same class — the
|
|
487
|
+
// target reader narrower than a shell word — and each was fixed at the instance quoted.
|
|
488
|
+
export function shellWords(command) {
|
|
478
489
|
const words = [];
|
|
479
490
|
let text = "";
|
|
480
491
|
let open = false; // a word has begun — so an empty `""` argument survives as a word
|
|
@@ -535,11 +546,74 @@ function shellWords(command) {
|
|
|
535
546
|
// A backslash-newline is a line continuation: both characters vanish, and the word
|
|
536
547
|
// continues on the next line. Appending the newline instead would have glued it into the
|
|
537
548
|
// middle of a path, which no target matches.
|
|
538
|
-
// `\r\n` is consumed as a PAIR
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
// answers `deny` — the constitution, reachable by
|
|
542
|
-
//
|
|
549
|
+
// `\r\n` is consumed as a PAIR, and **the reachability that argued for it has been
|
|
550
|
+
// retired.** What stood here from 2026-07-28 until 2026-08-25 was: skipping one character
|
|
551
|
+
// left the `\n` behind, a newline is an operator here, so `cp /tmp/x \\<CRLF>docs/vision.md`
|
|
552
|
+
// stepped aside where the LF spelling answers `deny` — "the constitution, reachable by
|
|
553
|
+
// editing the file on Windows". The first half is a true statement about THIS reader. The
|
|
554
|
+
// second is a claim about a shell, and it did not reproduce on any shell measured.
|
|
555
|
+
//
|
|
556
|
+
// **Measured 2026-08-25 on five shells, with a neutral target path — never the
|
|
557
|
+
// constitution.** `bash 3.2.57(1)` (arm64-apple-darwin25, the maintainer's machine),
|
|
558
|
+
// `bash 5.2.15(1)` (aarch64-unknown-linux-gnu) and `bash 5.2.37(1)` (x86_64-pc-linux-gnu),
|
|
559
|
+
// both in containers; `zsh 5.9`; and **the measured host's `/bin/sh`**, which on that macOS host
|
|
560
|
+
// resolves to bash 3.2.57 in POSIX mode — elsewhere `/bin/sh` is commonly `dash` or `busybox`
|
|
561
|
+
// and NEITHER was measured, so this row is a fifth reading of bash and zsh rather than a claim
|
|
562
|
+
// about `/bin/sh` as such.
|
|
563
|
+
// **All five agree, and none of them joins the pair:** the backslash escapes the `\r`, the
|
|
564
|
+
// newline then ends the command, and the fragment after it is run as its own command. The
|
|
565
|
+
// LF spelling in the same harness DID join and DID write the target, so the harness fires
|
|
566
|
+
// rather than reporting nothing everywhere. For the `cp`-shaped payload the retired
|
|
567
|
+
// sentence names, the target is left byte-for-byte unchanged on all five — so this branch
|
|
568
|
+
// is a **false red** there, fail-closed, and worth one prompt.
|
|
569
|
+
//
|
|
570
|
+
// **It is not a false red everywhere, which is why the branch is still here.** A shell
|
|
571
|
+
// applies a redirection BEFORE it looks the command up, so a clobbering `>` or `1>` on the
|
|
572
|
+
// fragment left by the split still fires and truncates its target to zero bytes — measured
|
|
573
|
+
// on all three bash builds. `>>` appends and does not. The carriers of that split verdict
|
|
574
|
+
// are `./fuzz-shell.mjs`'s `crlf-continuation-in-the-payload` EXPECT cells and
|
|
575
|
+
// `./compile.test.mjs`'s paired CRLF assertions. **Not `../.portulan/gate-map.md`**, which
|
|
576
|
+
// an earlier draft of this sentence named and which carries no CRLF entry at all — a
|
|
577
|
+
// citation to a record that does not exist, in the comment repairing exactly that.
|
|
578
|
+
//
|
|
579
|
+
// **Removing the branch is fail-OPEN on a gate matcher, so it is the maintainer's and not
|
|
580
|
+
// this comment's.** Deleting BOTH carriers in a scratch clone and running the recipes moves
|
|
581
|
+
// **four** surfaces, which is the count an accepting change has to carry:
|
|
582
|
+
// 1. `fuzz-shell` — the `crlf-continuation-in-the-payload|write-named` EXPECT cell, whose
|
|
583
|
+
// recorded divergence CLOSES. Good news, and a red until the record absorbs it.
|
|
584
|
+
// 2. `goldens` — the `a-CRLF-continuation` fixture in
|
|
585
|
+
// `../evals/goldens/gates/edit-the-constitution.json` regresses.
|
|
586
|
+
// 3. `./compile.test.mjs` — **two** direct assertions fail, *a CRLF continuation before
|
|
587
|
+
// the path* and *a CRLF continuation after `>`*.
|
|
588
|
+
// 4. `mutants` — exit **2, could-not-run**, downstream of 2: a census over a red corpus
|
|
589
|
+
// measures nothing, so it refuses until the fixture is repaired.
|
|
590
|
+
// **An earlier draft of this comment said "those two and nothing else" and had counted only
|
|
591
|
+
// the first two** — an exhaustive claim that had not run the suite it was exhaustive about,
|
|
592
|
+
// in the comment repairing an unmeasured claim. Reported by Copilot, round 1 on #342.
|
|
593
|
+
// What does NOT move, **and the reason an earlier draft gave for it was false.** That draft
|
|
594
|
+
// said "the redirection is recognised off the raw segment text rather than off a joined
|
|
595
|
+
// word". There is no raw-text path: `shellWrites` iterates `shellSegments`, which is built
|
|
596
|
+
// from `shellWords`. The real reason is narrower and is a property of WHERE the pair sits.
|
|
597
|
+
// With the branch gone the newline splits, and for the fuzzer's shape — pair after the head,
|
|
598
|
+
// `echo \<CRLF>ok > docs/vision.md` — the `> docs/vision.md` lands in a LATER segment whose
|
|
599
|
+
// `redirects` still name the target, so that cell stays `true`. Put the pair AFTER the `>`
|
|
600
|
+
// and the opposite happens: the operator takes the escaped `\r` as its target and the path
|
|
601
|
+
// becomes an ordinary word in the next segment — which is precisely why surface 3's second
|
|
602
|
+
// assertion fails. **So the true positive survives for this production and NOT for every
|
|
603
|
+
// redirect spelling**, and generalising it was the overclaim. Reported by Copilot, rounds 2
|
|
604
|
+
// and 3 on #342. A real LF continuation is untouched either way. Asked at
|
|
605
|
+
// `../.portulan/proposals/0031-a-continuation-no-shell-joins.md`; until that is ruled, the
|
|
606
|
+
// branch stays and this comment states what was measured rather than what was argued.
|
|
607
|
+
//
|
|
608
|
+
// **The honest limit, and it is the platform the retired sentence named: no Windows-side
|
|
609
|
+
// bash was measured** — not git-bash, MSYS2, Cygwin (whose `igncr` exists precisely for
|
|
610
|
+
// this) or WSL. Nor was any bash 4.x. The standing rail is `./fuzz-shell.ground.test.mjs`,
|
|
611
|
+
// which runs this production under **whatever bash the host running the `tests` recipe
|
|
612
|
+
// has** and reds if that bash joins the pair — so a joining shell is caught wherever the
|
|
613
|
+
// recipe runs, CI included, and is caught nowhere the recipe does not run. That boundary is
|
|
614
|
+
// stated because it is the whole of the enforcement: the container measurements above are a
|
|
615
|
+
// record, not a recipe, since a recipe that pulled an image would be a network call in CI.
|
|
616
|
+
// Found by Copilot review on #60; the claim retired by measurement 2026-08-25.
|
|
543
617
|
if (command[i + 1] === "\r" && command[i + 2] === "\n") {
|
|
544
618
|
i += 2;
|
|
545
619
|
continue;
|
|
@@ -648,6 +722,85 @@ function normalisePath(p) {
|
|
|
648
722
|
return (absolute ? "/" : "") + out.join("/");
|
|
649
723
|
}
|
|
650
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Does this target match NO path a host actually submits? `true` means none can — the rule matches
|
|
727
|
+
* nothing, whatever it says.
|
|
728
|
+
*
|
|
729
|
+
* _(That first line asked the inverse question until 2026-09-09 — "can any path fall under this target?
|
|
730
|
+
* `false` means none can" — so it described a return value the function does not have. Eight
|
|
731
|
+
* fresh-context gradings read past it, four of them grading this docblock's accuracy: every one checked
|
|
732
|
+
* whether the predicate was right and none checked whether its opening sentence matched its own
|
|
733
|
+
* `return`. Found by Copilot on #408.)_
|
|
734
|
+
*
|
|
735
|
+
* **The predicate is a comparison between two forms of the target, and that is what makes it the
|
|
736
|
+
* class rather than a list.** `matchesPath` compares a *tail* — it strips a leading `./` and any
|
|
737
|
+
* leading `/` and then asks whether the candidate ends with, or contains, what is left. A host is
|
|
738
|
+
* **expected** to hand over a normalised absolute path — no `.` segment, no empty segment, no
|
|
739
|
+
* backslash — and that is an assumption rather than something measured here, argued below. So the
|
|
740
|
+
* target can match only if its comparison form is already normalised. Where the two forms differ, the comparison is against
|
|
741
|
+
* a spelling no candidate can carry, and the rule matches nothing whatever it says.
|
|
742
|
+
*
|
|
743
|
+
* That covers three families at once, which is why it is written as a comparison and not as a list of
|
|
744
|
+
* spellings a reader would have to keep in step:
|
|
745
|
+
*
|
|
746
|
+
* - **The target that reduces to nothing**, which compiles to `Edit(./)`, `Edit(./.)`, or — for one
|
|
747
|
+
* spelling — the real subtree glob `Edit(././**)`.
|
|
748
|
+
* - **The target with an interior `.` or empty segment**, each compiling to a named surface that reads
|
|
749
|
+
* like a real gate on a plausible path, and matches nothing.
|
|
750
|
+
* - **The target carrying a backslash**, and this one is why the argument above needs its second half
|
|
751
|
+
* stated: `matchesPath` normalises the **candidate**'s backslashes to `/` and never the target's, so
|
|
752
|
+
* a backslash in a target is compared against a character no candidate can still contain, **on every
|
|
753
|
+
* platform**. Unlike the first two families this one has no conditional reading at all.
|
|
754
|
+
*
|
|
755
|
+
* **The host assumption, named because it is one.** *No `.` segment, no empty segment, no backslash*
|
|
756
|
+
* describes what a host is expected to send, and this repository does not control that and has not
|
|
757
|
+
* measured an unnormalised `file_path` arriving. The refusal does not rest on it: a rule matching only
|
|
758
|
+
* `/repo/x/.` gates nothing anybody meant to gate whichever way the assumption falls.
|
|
759
|
+
*
|
|
760
|
+
* **Four cuts, three of them incomplete, always in the same direction — and that arc is why this is a
|
|
761
|
+
* comparison rather than a list.** Cut 1 lifted `matchesPath`'s own strip and caught two of the first
|
|
762
|
+
* family's five. Cut 2 asked whether the target normalised to **empty**, catching that family alone
|
|
763
|
+
* while three carriers of its prose claimed the class. Cut 3 added the interior family. Cut 4 added the
|
|
764
|
+
* backslashes. Every miss was found by putting neighbouring spellings through the function at a
|
|
765
|
+
* checkpoint, never by re-reading the sentence. A predicate defined by the instances it was written for
|
|
766
|
+
* is ../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md's shape, and **this
|
|
767
|
+
* docblock is the one carrier of that arc** — the records cite it rather than recounting it.
|
|
768
|
+
*
|
|
769
|
+
* The thirteen spellings and the controls that must stay matchable are enumerated **and measured** in
|
|
770
|
+
* `./compile.test.mjs`'s `NEVER` and `CONTROLS` — the only carrier that measures them. Four records
|
|
771
|
+
* enumerate them for a reader; this docblock cites the suite rather than joining them.
|
|
772
|
+
*
|
|
773
|
+
* **`matchesPath` is left byte-identical on purpose**, and that is a decision rather than an omission:
|
|
774
|
+
* its answers are asserted by twelve `documented-hole` cases in ../evals/goldens/gates/ and by
|
|
775
|
+
* `mutants` operators anchored on its text, so moving its strip would move two census anchors and exit
|
|
776
|
+
* the census 2 — could-not-run wearing a pass's clothes. This function re-spells that strip rather than
|
|
777
|
+
* importing it, which is one carrier more than ideal and is pinned by a suite case comparing the two on
|
|
778
|
+
* every target below.
|
|
779
|
+
*
|
|
780
|
+
* The subtree marker is **meaning, not spelling**: exactly one trailing `/` says *the subtree* and is
|
|
781
|
+
* removed before the comparison, which is why `docs/` is a real target and `docs//` is not.
|
|
782
|
+
*
|
|
783
|
+
* What this does NOT answer is what `./` should MEAN as a policy target. That is
|
|
784
|
+
* https://github.com/sleepy-panda-srl/portulan/issues/337's reserved question, and this predicate
|
|
785
|
+
* answers only whether a target can *never* match.
|
|
786
|
+
*/
|
|
787
|
+
export function neverMatches(target) {
|
|
788
|
+
const clean = String(target ?? "").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
789
|
+
// `clean === "/"` is UNREACHABLE, and so is the `body === ""` arm below — the strip above removes
|
|
790
|
+
// every leading slash, so `"/"` arrives here as `""`. Measured, not assumed. They are kept anyway
|
|
791
|
+
// because `matchesPath` carries the identical pair one screen down and this predicate is meant to be
|
|
792
|
+
// read beside it: deleting them here would make two functions that answer the same question about
|
|
793
|
+
// the same string look different, to save two comparisons nobody runs. Copilot raised it on #408;
|
|
794
|
+
// the answer is that the redundancy is deliberate and now says so, which is the half it was missing.
|
|
795
|
+
if (clean === "" || clean === "/") return true;
|
|
796
|
+
const body = clean.endsWith("/") ? clean.slice(0, -1) : clean;
|
|
797
|
+
// The backslash arm is separate because it is about the CANDIDATE's normalisation rather than the
|
|
798
|
+
// target's: `matchesPath` rewrites `\\` to `/` in what the host hands over, so a target keeping one
|
|
799
|
+
// is compared against a character the comparison has already removed from every candidate.
|
|
800
|
+
if (body.includes("\\")) return true;
|
|
801
|
+
return body === "" || normalisePath(body) !== body;
|
|
802
|
+
}
|
|
803
|
+
|
|
651
804
|
/**
|
|
652
805
|
* The directories a target lives under, as exact paths: `a/b/c.md` -> `a`, `a/b`.
|
|
653
806
|
*
|
|
@@ -718,6 +871,8 @@ function commandSegments(raw) {
|
|
|
718
871
|
const out = [];
|
|
719
872
|
let start = 0;
|
|
720
873
|
let quote = null;
|
|
874
|
+
// The index of the character a backslash most recently turned into data. See the `\\` branch.
|
|
875
|
+
let escaped = -1;
|
|
721
876
|
for (let i = 0; i < command.length; i += 1) {
|
|
722
877
|
const c = command[i];
|
|
723
878
|
if (quote) {
|
|
@@ -747,7 +902,28 @@ function commandSegments(raw) {
|
|
|
747
902
|
// of one — so this is fixed for the reason the session kept re-learning rather than for a
|
|
748
903
|
// failing case: one carrier corrected and its sibling left is how the last three defects
|
|
749
904
|
// on this branch happened.
|
|
905
|
+
// **The pair's two carriers are this line and `shellWords`, and the measurement retiring
|
|
906
|
+
// the reachability claim is written up there once.** No shell measured joins the pair, so
|
|
907
|
+
// consuming it here is unfaithful to a shell in the same direction — **but this carrier
|
|
908
|
+
// records no false red of its own, and an earlier draft of this comment said it did.** On
|
|
909
|
+
// the shell path its `crlf-continuation-in-the-payload|shell` cell answers `false`, the
|
|
910
|
+
// correct negative: nothing gated runs there. On the write path it is an UNWRAPPER feeding
|
|
911
|
+
// `shellWrites`, reached through a short-circuiting `||` after the un-peeled spellings, so
|
|
912
|
+
// the recorded false red — `write-named` — is produced by `shellWords` and not here. **The
|
|
913
|
+
// reason to keep the two readers in step is synchronisation, not a shared verdict**: a
|
|
914
|
+
// carrier corrected while its sibling stands is how the last several defects on this line
|
|
915
|
+
// happened. Reported by Copilot, round 2 on #342. That is why
|
|
916
|
+
// `../.portulan/proposals/0031-a-continuation-no-shell-joins.md` names both carriers rather
|
|
917
|
+
// than the one whose comment argued for it. Reported by Copilot, round 1 on #342.
|
|
750
918
|
i += command[i + 1] === "\r" && command[i + 2] === "\n" ? 2 : 1;
|
|
919
|
+
// **Remember WHICH character this backslash turned into data.** The redirection-operator
|
|
920
|
+
// check below reads one raw neighbour, and a raw read cannot tell `>` the operator from
|
|
921
|
+
// `\>` the literal. Without this, `echo \>| git push --force origin main` — a REAL pipe,
|
|
922
|
+
// measured in bash delivering bytes downstream — stopped splitting at the `|`, and the
|
|
923
|
+
// force-push after it went invisible to every matcher. `\>&` did the same across a real
|
|
924
|
+
// background separator. Both were caught at HEAD and were narrowed by the #71 fix itself;
|
|
925
|
+
// found by the pre-commit checkpoint, which attacked the change rather than reading it.
|
|
926
|
+
escaped = i;
|
|
751
927
|
continue;
|
|
752
928
|
}
|
|
753
929
|
// `#` is NOT treated as starting a comment, and that is a decision rather than an oversight.
|
|
@@ -764,13 +940,91 @@ function commandSegments(raw) {
|
|
|
764
940
|
//
|
|
765
941
|
// Both spellings are asserted in the suite so this stays a choice rather than drift.
|
|
766
942
|
// Reported by Copilot review on #60 and declined on those grounds.
|
|
943
|
+
// An `&` or `|` bound into a redirection OPERATOR is not a separator, and splitting there is
|
|
944
|
+
// what made the strip below unreachable for three of the four spellings. `2>&1 git push
|
|
945
|
+
// --force …` split at the `&` into `2>` and `1 git push --force …`, whose head is `1` — the
|
|
946
|
+
// redirection was already in pieces by the time anything could strip it, and `>| f git push
|
|
947
|
+
// --force …` broke the same way at the `|`. Both operators are closed forms: `&` preceded by
|
|
948
|
+
// `>`/`<` or followed by `>`, and `|` preceded by `>`. `a && b`, `a & b` and `a | b` are
|
|
949
|
+
// untouched — none of them puts a redirection character next to the separator.
|
|
950
|
+
// `i - 1 !== escaped` on both arms: a `>` or `<` that a backslash turned into DATA is not an
|
|
951
|
+
// operator, and treating it as one un-splits a real separator. The `command[i + 1]` arm needs
|
|
952
|
+
// no such guard — an escaped character is always preceded by its own backslash, so the
|
|
953
|
+
// character after `&` can never be an escaped `>`.
|
|
954
|
+
const opBefore = i - 1 !== escaped && (command[i - 1] === ">" || command[i - 1] === "<");
|
|
955
|
+
if (c === "&" && (opBefore || command[i + 1] === ">")) continue;
|
|
956
|
+
if (c === "|" && i - 1 !== escaped && command[i - 1] === ">") continue;
|
|
767
957
|
if (";|&()\n\r".includes(c)) {
|
|
768
958
|
out.push(command.slice(start, i));
|
|
769
959
|
start = i + 1;
|
|
770
960
|
}
|
|
771
961
|
}
|
|
772
962
|
out.push(command.slice(start));
|
|
773
|
-
return out.map((s) => s.trim()).filter(Boolean);
|
|
963
|
+
return out.map((s) => stripLeadingRedirections(s.trim())).filter(Boolean);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* A redirection sitting where the command goes: an optional file descriptor, one of the operators
|
|
968
|
+
* below, and a word.
|
|
969
|
+
*
|
|
970
|
+
* **This is the one leader that is stripped, and the asymmetry is the point** (#71). The other
|
|
971
|
+
* leaders `commandSegments` leaves alone — `env`, `sudo`, `FOO=bar`, `then`, `do`, a brace group —
|
|
972
|
+
* are refused for a reason that does not apply here: a table of command prefixes has no natural edge
|
|
973
|
+
* (`nice`, `time`, `nohup`, `timeout`, `command`, `stdbuf`, `doas`), and one missing entry buys
|
|
974
|
+
* exactly the false confidence a hole list exists to deny. A redirection's grammar is CLOSED, so it
|
|
975
|
+
* can be stripped completely with an edge a reader can check. #60 left it open deliberately, because
|
|
976
|
+
* closing it would have been a matcher change on the same commit that stopped hole 2 overclaiming.
|
|
977
|
+
*
|
|
978
|
+
* **Do not extend this to a named table of command prefixes.** That is the change the paragraph
|
|
979
|
+
* above refuses, and the two look similar enough to be proposed together.
|
|
980
|
+
*
|
|
981
|
+
* Applied repeatedly, because redirections stack: `> /tmp/out 2>&1 git push --force …` carries two.
|
|
982
|
+
*/
|
|
983
|
+
/**
|
|
984
|
+
* The redirection TARGET, as one word.
|
|
985
|
+
*
|
|
986
|
+
* `[^\s]+` was wrong and shipped for exactly one review round: a redirection target may contain
|
|
987
|
+
* spaces when it is quoted or escaped, and `> "foo bar" git push --force …` then stripped only
|
|
988
|
+
* `> "foo`, leaving `bar" git push --force …` — head `bar"`, no gate, and the segment text corrupted
|
|
989
|
+
* into the bargain. Measured escaping on five spellings (both quote styles, an escaped space, `2>` and
|
|
990
|
+
* `>|`), and bash confirmed to run the command after each. **A fix that closes a hole by a width its
|
|
991
|
+
* own test never probed**, found by Copilot round 1 — which named the double-quoted case; the other
|
|
992
|
+
* four are its siblings and are closed in the same stroke rather than left for the next round.
|
|
993
|
+
*
|
|
994
|
+
* A word is therefore a run of quoted spans, backslash-escaped characters, and ordinary non-space
|
|
995
|
+
* characters — the same three things `shellWords` recognises, kept to a regex here because this reader
|
|
996
|
+
* needs the target's EXTENT and never its value.
|
|
997
|
+
*
|
|
998
|
+
* **Inside a double-quoted span a backslash escapes the next character, and inside a single-quoted one
|
|
999
|
+
* it does not.** POSIX gives `'…'` no escapes at all, so honouring one there would invent a hole —
|
|
1000
|
+
* which is the same asymmetry `commandSegments`'s own quote loop already carries, and the same one
|
|
1001
|
+
* `shellWords` carries at its quoted-run branch.
|
|
1002
|
+
*
|
|
1003
|
+
* **This is the THIRD review finding of one class, and the class is what the suite now pins.** Round 1
|
|
1004
|
+
* found `[^\s]+` too narrow for a quoted target; round 3 found `"[^"]*"` too narrow for an escaped
|
|
1005
|
+
* quote *inside* one — `> "foo \"bar baz\"" git push --force …`, measured escaping while bash was
|
|
1006
|
+
* measured creating the file and running the command. Both times the fix landed at the spelling that
|
|
1007
|
+
* was quoted. So `compile.test.mjs` now asserts this pattern against `shellWords` itself: every
|
|
1008
|
+
* spelling that reader calls ONE word must be consumed whole here. A fourth sibling cannot be found by
|
|
1009
|
+
* a reviewer first.
|
|
1010
|
+
*
|
|
1011
|
+
* Linear rather than backtracking-prone despite the alternation: the pattern is anchored, the trailing
|
|
1012
|
+
* `\s*` matches empty, and the `+` is greedy over a match that always succeeds once an operator has
|
|
1013
|
+
* been seen — so there is no failure to backtrack into. Probed to 200KB.
|
|
1014
|
+
*/
|
|
1015
|
+
const REDIRECTION_TARGET = String.raw`(?:"(?:\\[\s\S]|[^"\\])*"|'[^']*'|\\[\s\S]|[^\s])+`;
|
|
1016
|
+
|
|
1017
|
+
const LEADING_REDIRECTION = new RegExp(String.raw`^\d*(?:&>>|&>|>>|>&|>\||<&|<>|<|>)\s*${REDIRECTION_TARGET}\s*`);
|
|
1018
|
+
|
|
1019
|
+
function stripLeadingRedirections(segment) {
|
|
1020
|
+
let text = segment;
|
|
1021
|
+
for (;;) {
|
|
1022
|
+
const shorter = text.replace(LEADING_REDIRECTION, "");
|
|
1023
|
+
// A segment that is ONLY a redirection strips to the empty string and is dropped by the
|
|
1024
|
+
// caller's `filter(Boolean)`, which is correct: a redirection is not a command.
|
|
1025
|
+
if (shorter === text) return text;
|
|
1026
|
+
text = shorter;
|
|
1027
|
+
}
|
|
774
1028
|
}
|
|
775
1029
|
|
|
776
1030
|
/**
|
|
@@ -873,8 +1127,32 @@ export function matchesRule(rule, tool, input = {}) {
|
|
|
873
1127
|
// wrapper and made `bash -c "bash -c 'git push origin HEAD'"` match, contradicting hole 1's
|
|
874
1128
|
// documented and asserted limit. The suite caught it, which is what that counterexample test
|
|
875
1129
|
// is for. Reading the raw command in both branches keeps the budget at exactly one unwrap.
|
|
876
|
-
|
|
877
|
-
|
|
1130
|
+
//
|
|
1131
|
+
// **A spelling is SEGMENTED as well as tested whole, and that half was missing until
|
|
1132
|
+
// 2026-08-25.** The two calls above unwrap the raw command and unwrap each of its segments,
|
|
1133
|
+
// which closes a wrapper before a separator — `ls && bash -c "git push --force origin main"`
|
|
1134
|
+
// — and leaves a separator inside a wrapper wide open: `bash -c "ls; git push --force origin
|
|
1135
|
+
// main"` answered **false**, and so did every other Gated shell action behind one wrapper and
|
|
1136
|
+
// one separator. Bash runs it; the gate did not see it.
|
|
1137
|
+
//
|
|
1138
|
+
// **The write branch below never had this gap, and the reason is why the fix is shaped like
|
|
1139
|
+
// this.** Its `writes` callback is `shellWrites`, which segments AGAIN internally with
|
|
1140
|
+
// `shellSegments` — so the write half got a third segmentation for free while the shell half,
|
|
1141
|
+
// whose `hit` is a plain prefix test, got none. One fix landing in one carrier and not its
|
|
1142
|
+
// sibling, between two branches of one function, for the second time
|
|
1143
|
+
// (`proposals/0020`). Found by ./fuzz-shell.mjs, which
|
|
1144
|
+
// generates the grammar rather than reading it.
|
|
1145
|
+
//
|
|
1146
|
+
// `reach` is used on BOTH arms deliberately: without it on the second,
|
|
1147
|
+
// `ls && bash -c "x; git push --force …"` — a separator outside AND inside — still escaped.
|
|
1148
|
+
// Fixing only the spelling that was quoted is the class this repository keeps meeting.
|
|
1149
|
+
//
|
|
1150
|
+
// **The unwrap budget is still exactly one level**, which is hole 1's documented and asserted
|
|
1151
|
+
// limit. `spellings` is called once on each path here and `commandSegments` peels nothing, so
|
|
1152
|
+
// `bash -c "sh -c 'git push origin HEAD'"` stays false — asserted, because an earlier draft of
|
|
1153
|
+
// the segment composition did peel twice and the suite is what caught it.
|
|
1154
|
+
const reach = (s) => hit(s) || commandSegments(s).some(hit);
|
|
1155
|
+
return spellings(input.command).some(reach) || commandSegments(input.command).some((seg) => spellings(seg).some(reach));
|
|
878
1156
|
}
|
|
879
1157
|
if (typeof action.write === "string") {
|
|
880
1158
|
if (WRITE_TOOLS.includes(tool)) return matchesPath(input.file_path ?? input.notebook_path, action.write);
|
|
@@ -970,6 +1248,16 @@ const HOST_TIER_NOT_A_GATE = {
|
|
|
970
1248
|
propose: "tier `propose` is enforced by the platform floor — pull requests, required checks, review — not by a tool-level permission rule on this machine",
|
|
971
1249
|
};
|
|
972
1250
|
|
|
1251
|
+
/**
|
|
1252
|
+
* The compiled-hook runners, in the order `claudeCode` spells them: the PreToolUse gate, then the Stop
|
|
1253
|
+
* gate. **This is their one carrier.** They are invoked by generated host configuration rather than
|
|
1254
|
+
* imported by anything, so no import graph can find them and every other roster that needs to know
|
|
1255
|
+
* which `cli/` modules are runners has to ask here — `./payload.mjs` does. A third runner added below
|
|
1256
|
+
* without a name added here would classify as unreachable in that rail, which is the direction that
|
|
1257
|
+
* fails loudly rather than the one that ships something unnoticed.
|
|
1258
|
+
*/
|
|
1259
|
+
export const HOOK_RUNNERS = ["gate.mjs", "stop-gate.mjs"];
|
|
1260
|
+
|
|
973
1261
|
/**
|
|
974
1262
|
* Translate the policy into a Claude Code settings object.
|
|
975
1263
|
*
|
|
@@ -1034,8 +1322,8 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1034
1322
|
pinned.push({ file, abs });
|
|
1035
1323
|
return `"${abs}"`;
|
|
1036
1324
|
};
|
|
1037
|
-
const runner = options.runner ?? spell(
|
|
1038
|
-
const stopRunner = options.stopRunner ?? spell(
|
|
1325
|
+
const runner = options.runner ?? spell(HOOK_RUNNERS[0]);
|
|
1326
|
+
const stopRunner = options.stopRunner ?? spell(HOOK_RUNNERS[1]);
|
|
1039
1327
|
|
|
1040
1328
|
const compiled = [];
|
|
1041
1329
|
const refused = [];
|
|
@@ -1043,6 +1331,8 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1043
1331
|
const ask = [];
|
|
1044
1332
|
const matchers = new Set();
|
|
1045
1333
|
const shellWriteGates = [];
|
|
1334
|
+
// Write gates whose single emitted permission pattern is `Edit(path)` — see the note they produce.
|
|
1335
|
+
const editCoveredGates = [];
|
|
1046
1336
|
|
|
1047
1337
|
for (const rule of parsed.rules) {
|
|
1048
1338
|
if (!HOST_GATE_TIERS.has(rule.tier)) {
|
|
@@ -1056,6 +1346,60 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1056
1346
|
refused.push({ id: rule.id, tier: rule.tier, why: rule.target });
|
|
1057
1347
|
continue;
|
|
1058
1348
|
}
|
|
1349
|
+
// ---- a path target no path can match, refused HERE and not at `parse`
|
|
1350
|
+
//
|
|
1351
|
+
// **Hole 8 of ../.portulan/gate-map.md, closed at the tier that asks the question.** A
|
|
1352
|
+
// `gated` or `prohibited` rule whose path target can never match — see `neverMatches` for the
|
|
1353
|
+
// three families, of which *reduces to nothing* is only the first — compiles to a named
|
|
1354
|
+
// permission surface (`Edit(./)`, `Edit(./.)`, `Edit(././**)`, `Edit(./docs/./vision.md)`,
|
|
1355
|
+
// `Edit(./docs\\vision.md)`) while `matchesRule` answers `false` for every path a host submits. The compiler then reports the rule COMPILED, and
|
|
1356
|
+
// `doctor` counts it as covered. That is a hollow gate that reads from outside exactly like a
|
|
1357
|
+
// whole one: hole 3's failure mode reached by a different road.
|
|
1358
|
+
//
|
|
1359
|
+
// **Why a throw and not a `refused` row.** `refused` is the accounting for a rule a backend
|
|
1360
|
+
// legitimately declines — an `auto` tier, a `none` kind — and it writes the artifact without
|
|
1361
|
+
// the rule and exits 0. Filing this there would leave `doctor` reporting a gate the policy
|
|
1362
|
+
// declares and nothing enforces as ordinary non-coverage, which is precisely the laundering
|
|
1363
|
+
// `parse`'s own target refusals (absolute, `..`, whitespace) already decline to do. A policy
|
|
1364
|
+
// that cannot be enforced as written is malformed, and malformed is could-not-run.
|
|
1365
|
+
//
|
|
1366
|
+
// **Why here and not at `parse`.** That stage holds no tier partition, by a decision recorded
|
|
1367
|
+
// in its own docblock: *"A partition that is one backend's opinion must live in that backend,
|
|
1368
|
+
// or the second one is structurally incapable of disagreeing."* A session once put the
|
|
1369
|
+
// `auto`/`propose` refusals there and the floor backend then needed the rules it had thrown
|
|
1370
|
+
// away. This is that same shape — the hazard exists only for the tiers THIS backend enforces —
|
|
1371
|
+
// so it lives with `HOST_GATE_TIERS` above it, and the two `auto` rules in
|
|
1372
|
+
// ../.portulan/gates.json — `edit-on-a-working-branch` (`write: "./"`) and
|
|
1373
|
+
// `read-anything-in-the-repository` (`read: "./"`), one of each path kind — keep their spelling
|
|
1374
|
+
// and are refused one line earlier, for their tier, exactly as before.
|
|
1375
|
+
//
|
|
1376
|
+
// **One site, and the predicate exported rather than inlined.** The floor backend compiles no
|
|
1377
|
+
// path target at all (see `floorRefusal`), so there is no second site to sweep today and
|
|
1378
|
+
// saying "the same treatment there" would be a claim about code that does not exist.
|
|
1379
|
+
// `neverMatches` is exported for the backend that does compile one, and for the tests.
|
|
1380
|
+
//
|
|
1381
|
+
// **What this does NOT reach, said here rather than left to be discovered.** ./gate.mjs reads
|
|
1382
|
+
// the policy through `parse`, which this change does not touch, so a workspace that has
|
|
1383
|
+
// committed such a rule still loads it at the hook, `matchesRule` answers `false`, and the
|
|
1384
|
+
// hook steps aside because nothing matched. The rule is refused at COMPILE time — which is
|
|
1385
|
+
// when the artifact that gates the host is written — and the residue is recorded in hole 8.
|
|
1386
|
+
//
|
|
1387
|
+
// Found by the milestone-8 gate corpus on its first run (#336) and put as three options in
|
|
1388
|
+
// #337; this is the third, the narrowest, and it deliberately leaves open what `./` should
|
|
1389
|
+
// MEAN as a policy target.
|
|
1390
|
+
if ((rule.kind === "write" || rule.kind === "read") && neverMatches(rule.target)) {
|
|
1391
|
+
throw new CompileError(
|
|
1392
|
+
`rule \`${rule.id}\` is ${rule.tier} and its ${rule.kind} target ${JSON.stringify(rule.target)} matches no path a host ` +
|
|
1393
|
+
`can submit: the matcher compares a tail, and this target's comparison form is one no candidate can carry. ` +
|
|
1394
|
+
`This backend would emit ${pattern(rule.kind === "write" ? "Edit" : READ_TOOLS[0], rule.target)} ` +
|
|
1395
|
+
`and report the rule compiled, while the runtime matcher answered false for every input: a gate that reads as whole ` +
|
|
1396
|
+
`from outside and enforces nothing. Name the path the rule protects in its normalised spelling — no ` +
|
|
1397
|
+
`\`.\` or empty segment, no backslash — or drop the rule; and where this ` +
|
|
1398
|
+
`rule came from a pack, it is the pack that carries it, not the workspace composing it. ` +
|
|
1399
|
+
`(A never-matching target is harmless at \`auto\`, which this backend refuses one step earlier; it is a hollow gate only ` +
|
|
1400
|
+
`at the tiers that enforce. See .portulan/gate-map.md, honest holes, entry 8.)`,
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1059
1403
|
const into = rule.tier === "prohibited" ? deny : ask;
|
|
1060
1404
|
const emitted = []; // permission patterns — the layer that cannot fail open
|
|
1061
1405
|
const hookOnly = []; // coverage the permission DSL cannot express, carried by the hook alone
|
|
@@ -1063,11 +1407,36 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1063
1407
|
emitted.push(`Bash(${rule.target}:*)`);
|
|
1064
1408
|
matchers.add("Bash");
|
|
1065
1409
|
} else {
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1410
|
+
// **The PERMISSION pattern narrows to `Edit`; the HOOK matchers keep all three.** These two
|
|
1411
|
+
// lines used to be one loop, and that emitted `Write(path)` and `NotebookEdit(path)` beside
|
|
1412
|
+
// `Edit(path)` — patterns the host **discards**. Measured on Claude Code 2.1.240, printed on
|
|
1413
|
+
// every start of this repository:
|
|
1414
|
+
//
|
|
1415
|
+
// Permission deny rule (.claude/settings.json): Write(./docs/vision.md) is not matched by
|
|
1416
|
+
// file permission checks — only Edit(path) rules are. Use Edit(./docs/vision.md) instead
|
|
1417
|
+
// (Edit rules cover all file-editing tools).
|
|
1418
|
+
//
|
|
1419
|
+
// and the same for the `ask` tier, so this was never a `deny`-only defect. The gate held
|
|
1420
|
+
// throughout — `Edit(path)` is matched for every file-editing tool — so what was wrong was
|
|
1421
|
+
// the compiler's **accounting**: three rules reported compiled where one enforces, plus a
|
|
1422
|
+
// permanent warning, which is `../.portulan/memory/a-stated-enforcer-must-be-the-real-one.md`.
|
|
1423
|
+
//
|
|
1424
|
+
// **`matchers` is a different consumer and must not narrow with it.** It drives the
|
|
1425
|
+
// `PreToolUse` hook, where all three tool names genuinely arrive and `matchesRule` above
|
|
1426
|
+
// answers for each; narrowing it would open a real hole where today there is none. That is
|
|
1427
|
+
// why the repair is not narrowing `WRITE_TOOLS` — two consumers of one constant, one of
|
|
1428
|
+
// which changes. A suite case pins the matcher set for exactly this reason.
|
|
1429
|
+
//
|
|
1430
|
+
// **The dropped patterns are NOT `hookOnly`.** Measured with the pattern alone in `deny` and
|
|
1431
|
+
// `Write` allowed: the host refused the write. So their coverage is carried by the
|
|
1432
|
+
// permission layer, not by the hook, and filing them under a channel documented as *carried
|
|
1433
|
+
// by the hook alone* would be a false claim in the weakening direction. The fact goes on
|
|
1434
|
+
// `notes` instead, where it names its host version.
|
|
1435
|
+
const permissionTools = rule.kind === "write" ? ["Edit"] : READ_TOOLS;
|
|
1436
|
+
for (const tool of permissionTools) emitted.push(pattern(tool, rule.target));
|
|
1437
|
+
for (const tool of rule.kind === "write" ? WRITE_TOOLS : READ_TOOLS) matchers.add(tool);
|
|
1070
1438
|
if (rule.kind === "write") {
|
|
1439
|
+
editCoveredGates.push(rule.id);
|
|
1071
1440
|
// The hook is wired for Bash so that a shell spelling of this write reaches
|
|
1072
1441
|
// `matchesRule`, which now answers for it. **This line is the load-bearing half.**
|
|
1073
1442
|
// The matcher alone would be inert in any workspace whose policy declares no shell
|
|
@@ -1163,6 +1532,21 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1163
1532
|
`project, or pass \`--runner\`/\`--stop-runner\` to name a path you control`,
|
|
1164
1533
|
);
|
|
1165
1534
|
}
|
|
1535
|
+
if (editCoveredGates.length) {
|
|
1536
|
+
notes.push(
|
|
1537
|
+
`${editCoveredGates.length} write gate(s) — ${editCoveredGates.join(", ")} — emit \`Edit(path)\` as their only ` +
|
|
1538
|
+
`permission pattern, and that ONE pattern is matched for every file-editing tool. Measured on Claude Code ` +
|
|
1539
|
+
`2.1.240: with \`Edit(path)\` alone in the list and \`Write\` allowed, the host refused the write — and a ` +
|
|
1540
|
+
`\`Write\` to a DIFFERENT path succeeded in the same session, the control that tells *refused* from ` +
|
|
1541
|
+
`*refuses everything*. \`Write(path)\` ` +
|
|
1542
|
+
`and \`NotebookEdit(path)\` are NOT emitted because the host discards them — it says so on every start — and a ` +
|
|
1543
|
+
`compiler that emitted them would report three rules where one enforces. **Re-measure on a host change in EITHER ` +
|
|
1544
|
+
`direction:** this is a fact about one CLI version. If a later host stops treating \`Edit\` as tool-general, ` +
|
|
1545
|
+
`this gate narrows to one tool at the permission layer with nothing here going red. And no EARLIER host was ` +
|
|
1546
|
+
`re-measured — on one that honoured all three patterns and did not treat \`Edit\` as tool-general, this ` +
|
|
1547
|
+
`emission removes two working rules. The hook covers all three tool names on any host.`,
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1166
1550
|
if (shellWriteGates.length) {
|
|
1167
1551
|
notes.push(
|
|
1168
1552
|
`${shellWriteGates.length} write gate(s) — ${shellWriteGates.join(", ")} — also match a Bash command that writes the ` +
|
|
@@ -1487,7 +1871,76 @@ export function matrix(parsed, options = {}) {
|
|
|
1487
1871
|
// ===========================================================================================
|
|
1488
1872
|
|
|
1489
1873
|
/**
|
|
1490
|
-
*
|
|
1874
|
+
* The same answer as `policyPath`, plus WHICH ARM produced it — and, where the manifest named
|
|
1875
|
+
* something unusable, WHY.
|
|
1876
|
+
*
|
|
1877
|
+
* `policyPath` returns a path either way, which is all a reader of the policy needs and is exactly
|
|
1878
|
+
* what made the caller unable to tell two different situations apart: a workspace that NAMES a policy
|
|
1879
|
+
* file which is missing (a genuine error — something is declared and absent), and a workspace that
|
|
1880
|
+
* names none at all (a legitimate shape, per this function's own note below). Both arrived at
|
|
1881
|
+
* `readJson` as `ENOENT` and were reported identically, so the second — documented here as legitimate
|
|
1882
|
+
* — surfaced as `cannot read the gate policy at …/gates.json`, which reads like a corrupt or deleted
|
|
1883
|
+
* file and sends the reader to look for one that was never supposed to exist.
|
|
1884
|
+
*
|
|
1885
|
+
* Found 2026-08-22 while booting the `sleepy-panda` workspace, which declares no `gates` key and
|
|
1886
|
+
* composes a pack contributing two gate rules: the compiler refused with an ENOENT that mentioned
|
|
1887
|
+
* neither fact. Reporting declaredness is what lets the caller say which of the two it is.
|
|
1888
|
+
*
|
|
1889
|
+
* **`declared: false` is four situations, not one, so it carries a `reason`.** The first cut reported
|
|
1890
|
+
* every one of them as "`workspace.json` has no top-level `gates` key", which is false for a manifest
|
|
1891
|
+
* that is absent and false for a `gates` value this function refused — the same two-situations-one-
|
|
1892
|
+
* sentence defect this change exists to fix, reproduced one level down. `reason` is `no-key`,
|
|
1893
|
+
* `no-manifest`, `refused`, or `declared`. Found by Copilot on this pull request.
|
|
1894
|
+
*/
|
|
1895
|
+
export function policyDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
1896
|
+
const base = path.join(workspaceRoot, workspaceDir);
|
|
1897
|
+
const manifest = path.join(base, "workspace.json");
|
|
1898
|
+
const fallback = (reason) => ({ file: path.join(workspaceRoot, workspaceDir, "gates.json"), declared: false, reason });
|
|
1899
|
+
let declared;
|
|
1900
|
+
try {
|
|
1901
|
+
declared = JSON.parse(fs.readFileSync(manifest, "utf8")).gates;
|
|
1902
|
+
} catch {
|
|
1903
|
+
// No manifest, or unreadable. `doctor` is the tool that judges a manifest; this one only needs
|
|
1904
|
+
// to know where the policy is, and the default is where it is when nothing says otherwise.
|
|
1905
|
+
return fallback("no-manifest");
|
|
1906
|
+
}
|
|
1907
|
+
if (declared === undefined) return fallback("no-key");
|
|
1908
|
+
// Validated against the schema's `filePath` shape before it is used, and containment is
|
|
1909
|
+
// checked after resolution rather than by pattern alone — a `../` chain passes any regex and
|
|
1910
|
+
// still escapes. Without this, a malformed or hand-edited manifest turns a hook that runs on
|
|
1911
|
+
// EVERY tool call into an arbitrary file read outside the workspace. `doctor` would refuse such
|
|
1912
|
+
// a manifest, but this runner must not depend on `doctor` having been run: the two tools have
|
|
1913
|
+
// no ordering between them, and the schema is the contract, not the sequence. Found by review.
|
|
1914
|
+
//
|
|
1915
|
+
// **`isInside`, not another hand-rolled spelling.** The containment test read
|
|
1916
|
+
// `!path.relative(base, resolved).startsWith("..")` until 2026-08-22 — the spelling
|
|
1917
|
+
// `./inside.mjs` exists to replace, which calls an ordinary directory named `..foo` an escape and
|
|
1918
|
+
// falls back for a policy that was inside all along. This module already imports `isInside` and
|
|
1919
|
+
// already carries the same warning at `recordedOrigin`, so the rule was in scope and unread.
|
|
1920
|
+
// Pre-existing, and surfaced by Copilot on the pull request that touched this line.
|
|
1921
|
+
//
|
|
1922
|
+
// **No count is asserted here.** A first draft called this "the fifth copy"; the pre-commit
|
|
1923
|
+
// checkpoint could not reproduce that figure and a census then found more, including one in THIS
|
|
1924
|
+
// module at `resolveWorkspace`. The census is #331 rather than this commit, because the remaining
|
|
1925
|
+
// sites do not share semantics — they disagree on the empty relative path and on absolute paths —
|
|
1926
|
+
// so each needs its own red and green. An unreproducible number is worse than none.
|
|
1927
|
+
if (typeof declared === "string" && declared.trim() && FILE_PATH.test(declared)) {
|
|
1928
|
+
const resolved = path.resolve(base, declared);
|
|
1929
|
+
if (resolved !== base && isInside(base, resolved)) return { file: resolved, declared: true, reason: "declared" };
|
|
1930
|
+
}
|
|
1931
|
+
// The manifest named something, and nothing it named is usable — a wrong type, an empty string, a
|
|
1932
|
+
// shape the schema refuses, or a path that escapes the workspace. The compiler is on the
|
|
1933
|
+
// conventional path, so the fallback owns the diagnostic; but this is NOT the state of a manifest
|
|
1934
|
+
// that named nothing, and a message conflating the two is the defect this change is about.
|
|
1935
|
+
return fallback("refused");
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* Where a workspace's gate policy lives — the path alone.
|
|
1940
|
+
*
|
|
1941
|
+
* Kept as the narrow answer because most callers want exactly that, and because every existing caller
|
|
1942
|
+
* and test was written against a string. `policyDeclaration` is the one that also says whether the
|
|
1943
|
+
* workspace named it, and why when it did not.
|
|
1491
1944
|
*
|
|
1492
1945
|
* Read from the manifest's top-level `gates` key, because that is what the key MEANS —
|
|
1493
1946
|
* `../spec/slots.md` calls it "a path to a JSON file the enforcement compiler reads", and `doctor`
|
|
@@ -1501,26 +1954,73 @@ export function matrix(parsed, options = {}) {
|
|
|
1501
1954
|
* refusing it would make the key required, which is a spec change nobody decided.
|
|
1502
1955
|
*/
|
|
1503
1956
|
export function policyPath(workspaceRoot, workspaceDir = ".portulan") {
|
|
1504
|
-
|
|
1505
|
-
|
|
1957
|
+
return policyDeclaration(workspaceRoot, workspaceDir).file;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* What to say when a workspace declares no gate policy and none is there by convention.
|
|
1962
|
+
*
|
|
1963
|
+
* This is a STATE, not a failure to read a file, and the difference is the whole point: `policyPath`'s
|
|
1964
|
+
* note calls an absent `gates` key "a legitimate shape, and refusing it would make the key required,
|
|
1965
|
+
* which is a spec change nobody decided" — and then the reader refused it anyway, with `ENOENT` on a
|
|
1966
|
+
* file the workspace never claimed to have.
|
|
1967
|
+
*
|
|
1968
|
+
* It stays a refusal (exit 2, nothing written), because nothing was compiled and a compiler that
|
|
1969
|
+
* reports success having emitted nothing is the failure this repository keeps writing checks against.
|
|
1970
|
+
* What changes is that the refusal names the actual state and what it costs.
|
|
1971
|
+
*
|
|
1972
|
+
* **The pack count is the load-bearing half.** A workspace in this shape may still compose packs
|
|
1973
|
+
* contributing gate fragments — `sleepy-panda` composes `rituals/checkpoints`, which contributes two
|
|
1974
|
+
* — and those rules silently reach nothing, because a fragment tightens a policy and there is none to
|
|
1975
|
+
* tighten. That is the fact a reader needs and the one the old message could not carry: it threw
|
|
1976
|
+
* before `packContributions` was ever called.
|
|
1977
|
+
*/
|
|
1978
|
+
function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, reason = "no-key") {
|
|
1979
|
+
const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
1980
|
+
// **One opening per arm, because the arm is the reader's first question.** `no-key` is the shape
|
|
1981
|
+
// this whole change defends as legitimate; `no-manifest` is a workspace that has not been
|
|
1982
|
+
// authored yet; `refused` is a manifest that DID name a policy and named one this compiler will
|
|
1983
|
+
// not read, which is neither of the other two and must not be told it has no `gates` key.
|
|
1984
|
+
const opening =
|
|
1985
|
+
reason === "refused"
|
|
1986
|
+
? `\`workspace.json\` names a gate policy this compiler will not read — its top-level ` +
|
|
1987
|
+
`\`gates\` key is not a relative path to a file inside the workspace — and there is no ` +
|
|
1988
|
+
`\`gates.json\` at ${policyFile} either.`
|
|
1989
|
+
: reason === "no-manifest"
|
|
1990
|
+
? `this workspace declares no gate policy — there is no readable \`workspace.json\` at ` +
|
|
1991
|
+
`${manifest}, and there is no \`gates.json\` at ${policyFile}.`
|
|
1992
|
+
: `this workspace declares no gate policy — \`workspace.json\` has no top-level \`gates\` key, ` +
|
|
1993
|
+
`and there is no \`gates.json\` at ${policyFile}.`;
|
|
1994
|
+
const lines = [`${opening} Nothing was compiled and nothing was written.`];
|
|
1995
|
+
let composed = null;
|
|
1506
1996
|
try {
|
|
1507
|
-
|
|
1508
|
-
// Validated against the schema's `filePath` shape before it is used, and containment is
|
|
1509
|
-
// checked after resolution rather than by pattern alone — a `../` chain passes any regex and
|
|
1510
|
-
// still escapes. Without this, a malformed or hand-edited manifest turns a hook that runs on
|
|
1511
|
-
// EVERY tool call into an arbitrary file read outside the workspace. `doctor` would refuse such
|
|
1512
|
-
// a manifest, but this runner must not depend on `doctor` having been run: the two tools have
|
|
1513
|
-
// no ordering between them, and the schema is the contract, not the sequence. Found by review.
|
|
1514
|
-
if (typeof declared === "string" && declared.trim() && FILE_PATH.test(declared)) {
|
|
1515
|
-
const resolved = path.resolve(base, declared);
|
|
1516
|
-
const inside = path.relative(base, resolved);
|
|
1517
|
-
if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) return resolved;
|
|
1518
|
-
}
|
|
1997
|
+
composed = packContributions(workspaceRoot, workspaceDir, packOptions);
|
|
1519
1998
|
} catch {
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1999
|
+
// The pack layer has refusals of its own — a shadowed pack, a malformed pack manifest, an
|
|
2000
|
+
// unresolvable root. Swallowed HERE and nowhere else: this message is about the missing policy,
|
|
2001
|
+
// and a pack refusal raised from inside a diagnostic would replace the answer with a different
|
|
2002
|
+
// question. Declare a policy and the very next run surfaces it on its own terms.
|
|
1522
2003
|
}
|
|
1523
|
-
|
|
2004
|
+
const contributions = composed?.contributions ?? [];
|
|
2005
|
+
const rules = contributions.reduce((n, c) => n + (c.fragments?.length ?? 0), 0);
|
|
2006
|
+
if (rules > 0) {
|
|
2007
|
+
const packs = contributions
|
|
2008
|
+
.filter((c) => (c.fragments?.length ?? 0) > 0)
|
|
2009
|
+
.map((c) => `\`${c.pack}\``)
|
|
2010
|
+
.join(", ");
|
|
2011
|
+
lines.push(
|
|
2012
|
+
`${rules} pack-contributed gate rule(s) from ${packs} are therefore NOT compiled: ` +
|
|
2013
|
+
`a fragment tightens a policy, and there is none here to tighten.`,
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
lines.push(
|
|
2017
|
+
reason === "refused"
|
|
2018
|
+
? "Give `gates` a relative path to a file inside the workspace directory, or remove the key " +
|
|
2019
|
+
"and let `gates.json` be found by convention."
|
|
2020
|
+
: "Declare one with `portulan new gate-policy`, or leave it undeclared deliberately — " +
|
|
2021
|
+
"a workspace with no gate policy is a legitimate shape, and this is a state rather than a fault.",
|
|
2022
|
+
);
|
|
2023
|
+
return lines.join("\n ");
|
|
1524
2024
|
}
|
|
1525
2025
|
|
|
1526
2026
|
// ===========================================================================================
|
|
@@ -2273,7 +2773,25 @@ export function run(argv, options = {}) {
|
|
|
2273
2773
|
// The workspace may be named as a repository root or as the workspace directory itself, and the
|
|
2274
2774
|
// second is how a feed-side workspace is reachable at all — see `resolveWorkspace`.
|
|
2275
2775
|
const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
|
|
2276
|
-
const policyFile =
|
|
2776
|
+
const { file: policyFile, declared: policyDeclared, reason: policyReason } = policyDeclaration(workspaceRoot, workspaceDir);
|
|
2777
|
+
// **Declared-and-missing and never-declared are different answers.** Only the first is a
|
|
2778
|
+
// failure to read something this workspace claimed to have; the second is a shape `policyPath`
|
|
2779
|
+
// documents as legitimate, and reporting it as `ENOENT` sent readers hunting for a deleted
|
|
2780
|
+
// file. Checked here rather than inside the reader because only the caller knows to name what
|
|
2781
|
+
// the absence costs — see `undeclaredPolicyMessage`. The fallback arm is itself FOUR states,
|
|
2782
|
+
// and `policyReason` is what lets the message name the one it is in rather than assert the
|
|
2783
|
+
// commonest: this comment read "two different answers" while the code below had four.
|
|
2784
|
+
if (!policyDeclared && !fs.existsSync(policyFile)) {
|
|
2785
|
+
throw new CompileError(
|
|
2786
|
+
undeclaredPolicyMessage(
|
|
2787
|
+
policyFile,
|
|
2788
|
+
workspaceRoot,
|
|
2789
|
+
workspaceDir,
|
|
2790
|
+
{ named: namedRoots, discovery: () => discoverPackRoots(), forced },
|
|
2791
|
+
policyReason,
|
|
2792
|
+
),
|
|
2793
|
+
);
|
|
2794
|
+
}
|
|
2277
2795
|
const policy = readJson(policyFile, "the gate policy");
|
|
2278
2796
|
// The cascade's middle layer, composed before the policy is parsed so that a pack's fragment
|
|
2279
2797
|
// is validated by exactly the code that validates a hand-written rule.
|