@christang/keel 5.61.0 → 5.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +25 -2
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/validate_plugin.py +168 -6
- package/src/core/config.js +97 -10
package/README.md
CHANGED
|
@@ -132,7 +132,7 @@ because a permission granted in conversation does not survive a context reset. D
|
|
|
132
132
|
`keel/config.yaml` instead:
|
|
133
133
|
|
|
134
134
|
```yaml
|
|
135
|
-
authorize: # accepted names: commit, push, release, archive, continuation
|
|
135
|
+
authorize: # accepted names: commit, push, release, archive, continuation, issue:<owner>/<repo>
|
|
136
136
|
- commit
|
|
137
137
|
- push
|
|
138
138
|
```
|
|
@@ -151,6 +151,16 @@ question — still stops. It authorizes no repository action and schedules nothi
|
|
|
151
151
|
whose vocabulary predates the word, the entry is unrecognized and the whole declaration authorizes
|
|
152
152
|
nothing until corrected — fail-closed, never a silent grant.
|
|
153
153
|
|
|
154
|
+
`issue:<owner>/<repo>`, the sixth name, is the only one that names the resource it reaches, and
|
|
155
|
+
it is refused without one. The other five act on the checkout the declaration sits in, so each is
|
|
156
|
+
already bounded by the repository you declared it in. The credentials that open an issue are not:
|
|
157
|
+
`gh` is account-wide, so a bare `issue` would reach every repository your account can touch —
|
|
158
|
+
silently the widest entry in the file, and wider than `push`. Naming the repository keeps the
|
|
159
|
+
grant the size of what it says. Keel carries that scope to `keel --doctor` and to the compiled
|
|
160
|
+
capsule and **does not enforce it**: it invokes no tracker client and cannot observe one, exactly
|
|
161
|
+
as it never commits on your behalf either. Closing an issue is not in scope and does not need to
|
|
162
|
+
be — a pull request body carrying `Closes #<n>` does that when it lands.
|
|
163
|
+
|
|
154
164
|
Three things the declaration is not:
|
|
155
165
|
|
|
156
166
|
- **Not a way past a gate.** It authorizes the action, never the proof. `keel gate task-complete`
|
|
@@ -158,7 +168,7 @@ Three things the declaration is not:
|
|
|
158
168
|
anything.
|
|
159
169
|
- **Not a trigger.** It removes a confirmation, not the step that reaches the action. Nothing
|
|
160
170
|
schedules itself, and no next task is selected for you.
|
|
161
|
-
- **Not open-ended.** The
|
|
171
|
+
- **Not open-ended.** The six names above are the whole vocabulary. An unrecognized entry is
|
|
162
172
|
reported with the accepted names and the declaration authorizes nothing until you fix it — a
|
|
163
173
|
typo never becomes a silent grant.
|
|
164
174
|
|
package/bin/keel.js
CHANGED
|
@@ -1776,7 +1776,7 @@ function gitConfigHooksPath(repo) {
|
|
|
1776
1776
|
|
|
1777
1777
|
function printStandingAuthorizationSurface(repo) {
|
|
1778
1778
|
process.stdout.write("\nStanding authorization:\n");
|
|
1779
|
-
const { declared, unknown, message } = readStandingAuthorization(repo);
|
|
1779
|
+
const { declared, scopes, unknown, message } = readStandingAuthorization(repo);
|
|
1780
1780
|
if (unknown.length > 0) {
|
|
1781
1781
|
printDoctorLine("authorize", "failed", message);
|
|
1782
1782
|
return false;
|
|
@@ -1789,9 +1789,32 @@ function printStandingAuthorizationSurface(repo) {
|
|
|
1789
1789
|
: "undeclared; every action stays hard-stop"
|
|
1790
1790
|
);
|
|
1791
1791
|
for (const action of STANDING_AUTHORIZATION_ACTIONS) {
|
|
1792
|
+
// Keyed on the action, never on the declared string. A scoped entry is
|
|
1793
|
+
// `issue:acme/widgets` in the file, so a membership test against the bare
|
|
1794
|
+
// name reports it `not authorized` on the same screen that has just listed
|
|
1795
|
+
// it as declared — a diagnostic contradicting itself six lines apart.
|
|
1796
|
+
if (!scopes.has(action)) {
|
|
1797
|
+
printDoctorLine(action, "not authorized");
|
|
1798
|
+
continue;
|
|
1799
|
+
}
|
|
1800
|
+
const scope = scopes.get(action);
|
|
1792
1801
|
printDoctorLine(
|
|
1793
1802
|
action,
|
|
1794
|
-
|
|
1803
|
+
"authorized",
|
|
1804
|
+
scope ? `scoped to ${scope}` : ""
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1807
|
+
// Said once, and only where it applies. Keel invokes no tracker client and
|
|
1808
|
+
// observes none that an agent runs, so the scope is a declaration carried to
|
|
1809
|
+
// the people who read it rather than a boundary anything holds. A reader who
|
|
1810
|
+
// took it for a sandbox would be relying on nothing.
|
|
1811
|
+
if ([...scopes.values()].some((scope) => scope !== null)) {
|
|
1812
|
+
printDoctorLine(
|
|
1813
|
+
"scope",
|
|
1814
|
+
"carried",
|
|
1815
|
+
"Keel records a scope and does not enforce it — it invokes no tracker "
|
|
1816
|
+
+ "client and cannot observe one, so the boundary is kept by whoever "
|
|
1817
|
+
+ "acts, not by this check"
|
|
1795
1818
|
);
|
|
1796
1819
|
}
|
|
1797
1820
|
return true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.62.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.62.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
|
|
|
38
38
|
"scripts/validate_plugin.py",
|
|
39
39
|
]
|
|
40
40
|
|
|
41
|
-
PACKAGE_VERSION = "5.
|
|
42
|
-
PROTOCOL_VERSION = "5.
|
|
41
|
+
PACKAGE_VERSION = "5.62.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.62.0"
|
|
43
43
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
44
44
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
45
45
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -19808,19 +19808,20 @@ def validate_continuation_docs_scenario() -> int:
|
|
|
19808
19808
|
|
|
19809
19809
|
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
19810
19810
|
for needle in (
|
|
19811
|
-
"accepted names: commit, push, release, archive, continuation"
|
|
19811
|
+
"accepted names: commit, push, release, archive, continuation, "
|
|
19812
|
+
"issue:<owner>/<repo>",
|
|
19812
19813
|
"next unchecked task of the same change",
|
|
19813
19814
|
"the stop that re-asks for an approval already given",
|
|
19814
|
-
"The
|
|
19815
|
+
"The six names above are the whole vocabulary.",
|
|
19815
19816
|
):
|
|
19816
19817
|
if needle not in readme:
|
|
19817
19818
|
report(f"{label}: README.md lacks: {needle}")
|
|
19818
19819
|
return 1
|
|
19819
19820
|
|
|
19820
19821
|
config_text = (ROOT / "keel/config.yaml").read_text(encoding="utf-8")
|
|
19821
|
-
if "commit, push, release, archive,\n# continuation" not in config_text:
|
|
19822
|
+
if "commit, push, release, archive,\n# continuation, issue:<owner>/<repo>" not in config_text:
|
|
19822
19823
|
report(
|
|
19823
|
-
f"{label}: keel/config.yaml's comment does not name the
|
|
19824
|
+
f"{label}: keel/config.yaml's comment does not name the six-name "
|
|
19824
19825
|
"vocabulary."
|
|
19825
19826
|
)
|
|
19826
19827
|
return 1
|
|
@@ -27626,6 +27627,163 @@ def validate_a_filter_drops_only_what_it_named_scenario() -> int:
|
|
|
27626
27627
|
return 0
|
|
27627
27628
|
|
|
27628
27629
|
|
|
27630
|
+
def validate_an_authorization_names_its_repository_scenario() -> int:
|
|
27631
|
+
"""Issue #136: the vocabulary had no name for a tracker write.
|
|
27632
|
+
|
|
27633
|
+
`change-close` routes an unresolved follow-up to a durable owner and accepts
|
|
27634
|
+
an absolute https reference, which in practice is a tracker issue — while the
|
|
27635
|
+
standing-authorization vocabulary had no way to say the agent may create one.
|
|
27636
|
+
The entry added for it is the first whose credential reaches further than the
|
|
27637
|
+
checkout the declaration sits in: `gh` is account-wide, so a bare `issue`
|
|
27638
|
+
would be the widest entry in a file whose other entries the checkout bounds.
|
|
27639
|
+
It therefore names the repository it reaches, and the bare form is refused.
|
|
27640
|
+
"""
|
|
27641
|
+
label = "an-authorization-names-its-repository"
|
|
27642
|
+
|
|
27643
|
+
with tempfile.TemporaryDirectory(prefix="keel-issue-scope-") as raw:
|
|
27644
|
+
root = Path(raw)
|
|
27645
|
+
|
|
27646
|
+
def fixture(name: str, body: str) -> Path:
|
|
27647
|
+
repo = root / name
|
|
27648
|
+
repo.mkdir()
|
|
27649
|
+
write_authorize_config(repo, body)
|
|
27650
|
+
return repo
|
|
27651
|
+
|
|
27652
|
+
# M1 — the scoped form is accepted, beside an ordinary entry so the
|
|
27653
|
+
# assertion is about this entry and not about the block parsing at all.
|
|
27654
|
+
scoped = fixture(
|
|
27655
|
+
"scoped", "authorize:\n - commit\n - issue:acme/widgets\n"
|
|
27656
|
+
)
|
|
27657
|
+
out = run_keel(scoped, "--doctor").stdout
|
|
27658
|
+
if "authorize: ok" not in out or "issue:acme/widgets" not in out:
|
|
27659
|
+
report(
|
|
27660
|
+
f"{label}: a scoped tracker entry was refused. `gh` is "
|
|
27661
|
+
"account-wide, so this is the one entry that has to name its "
|
|
27662
|
+
"repository, and it is the one the vocabulary rejects."
|
|
27663
|
+
)
|
|
27664
|
+
report(out)
|
|
27665
|
+
return 1
|
|
27666
|
+
if "commit: authorized" not in out:
|
|
27667
|
+
report(
|
|
27668
|
+
f"{label}: the entry beside the scoped one lost its "
|
|
27669
|
+
"authorization, so the scoped entry voided the declaration "
|
|
27670
|
+
"rather than joining it."
|
|
27671
|
+
)
|
|
27672
|
+
report(out)
|
|
27673
|
+
return 1
|
|
27674
|
+
|
|
27675
|
+
# The per-action line, which is the half a reader looks at. Without it
|
|
27676
|
+
# the same screen lists the entry as declared and reports the action as
|
|
27677
|
+
# unauthorized.
|
|
27678
|
+
if "issue: authorized" not in out or "acme/widgets" not in out.split(
|
|
27679
|
+
"issue: authorized", 1
|
|
27680
|
+
)[-1].split("\n", 1)[0]:
|
|
27681
|
+
report(
|
|
27682
|
+
f"{label}: the per-action line does not report the scoped entry "
|
|
27683
|
+
"as authorized and name its scope."
|
|
27684
|
+
)
|
|
27685
|
+
report(out)
|
|
27686
|
+
return 1
|
|
27687
|
+
if "issue: not authorized" in out:
|
|
27688
|
+
report(
|
|
27689
|
+
f"{label}: the doctor lists the entry as declared and reports "
|
|
27690
|
+
"`issue: not authorized` on the same screen, so the diagnostic "
|
|
27691
|
+
"contradicts itself."
|
|
27692
|
+
)
|
|
27693
|
+
report(out)
|
|
27694
|
+
return 1
|
|
27695
|
+
# D2 is unfixable by mechanism, so it is owed to wording: a reader must
|
|
27696
|
+
# not take the scope for a sandbox.
|
|
27697
|
+
if "does not enforce" not in out:
|
|
27698
|
+
report(
|
|
27699
|
+
f"{label}: the output does not say Keel carries the scope "
|
|
27700
|
+
"without enforcing it, so a reader can take it for a fence."
|
|
27701
|
+
)
|
|
27702
|
+
report(out)
|
|
27703
|
+
return 1
|
|
27704
|
+
|
|
27705
|
+
# M2 — the bare form is refused, and the refusal carries the form it
|
|
27706
|
+
# needs. The refusal alone is not the assertion: a bare `issue` was
|
|
27707
|
+
# already refused before this existed, by not being a name at all.
|
|
27708
|
+
bare = fixture("bare", "authorize:\n - commit\n - issue\n")
|
|
27709
|
+
out = run_keel(bare, "--doctor").stdout
|
|
27710
|
+
if "authorize: failed" not in out:
|
|
27711
|
+
report(f"{label}: a bare tracker entry was granted.")
|
|
27712
|
+
report(out)
|
|
27713
|
+
return 1
|
|
27714
|
+
if "issue:<owner>/<repo>" not in out:
|
|
27715
|
+
report(
|
|
27716
|
+
f"{label}: the bare-entry refusal does not name the form it "
|
|
27717
|
+
"requires, so a reader is told `issue` is not a name rather "
|
|
27718
|
+
"than that it is a name needing a scope."
|
|
27719
|
+
)
|
|
27720
|
+
report(out)
|
|
27721
|
+
return 1
|
|
27722
|
+
|
|
27723
|
+
# M3 — the shape is checked, on both sides of correct.
|
|
27724
|
+
for name, body in (
|
|
27725
|
+
("short", "authorize:\n - issue:acme\n"),
|
|
27726
|
+
("long", "authorize:\n - issue:acme/widgets/extra\n"),
|
|
27727
|
+
("empty-owner", "authorize:\n - issue:/widgets\n"),
|
|
27728
|
+
("empty-repo", "authorize:\n - issue:acme/\n"),
|
|
27729
|
+
):
|
|
27730
|
+
repo = fixture(name, body)
|
|
27731
|
+
out = run_keel(repo, "--doctor").stdout
|
|
27732
|
+
if "authorize: failed" not in out:
|
|
27733
|
+
report(
|
|
27734
|
+
f"{label}: a malformed scope ({name}) was accepted; a shape "
|
|
27735
|
+
"check that passes everything checks nothing."
|
|
27736
|
+
)
|
|
27737
|
+
report(out)
|
|
27738
|
+
return 1
|
|
27739
|
+
declared_entry = body.strip().splitlines()[-1].strip("- ")
|
|
27740
|
+
if declared_entry not in out:
|
|
27741
|
+
report(
|
|
27742
|
+
f"{label}: the refusal for {name} does not name the "
|
|
27743
|
+
f"offending entry {declared_entry!r}."
|
|
27744
|
+
)
|
|
27745
|
+
report(out)
|
|
27746
|
+
return 1
|
|
27747
|
+
|
|
27748
|
+
# M3 — and fail-closed is unchanged by the new form: a valid scoped
|
|
27749
|
+
# entry beside an unrecognized one authorizes nothing.
|
|
27750
|
+
mixed = fixture(
|
|
27751
|
+
"mixed", "authorize:\n - issue:acme/widgets\n - deploy\n"
|
|
27752
|
+
)
|
|
27753
|
+
out = run_keel(mixed, "--doctor").stdout
|
|
27754
|
+
if "authorize: failed" not in out or "deploy" not in out:
|
|
27755
|
+
report(f"{label}: an unrecognized entry beside a scoped one did not fail closed.")
|
|
27756
|
+
report(out)
|
|
27757
|
+
return 1
|
|
27758
|
+
if "issue: authorized" in out:
|
|
27759
|
+
report(
|
|
27760
|
+
f"{label}: the scoped entry stayed authorized beside an "
|
|
27761
|
+
"unrecognized one, so the declaration did not fail closed."
|
|
27762
|
+
)
|
|
27763
|
+
report(out)
|
|
27764
|
+
return 1
|
|
27765
|
+
|
|
27766
|
+
|
|
27767
|
+
# M3 — the new rendering did not turn an undeclared action into a
|
|
27768
|
+
# declared one.
|
|
27769
|
+
only_commit = fixture("only-commit", "authorize:\n - commit\n")
|
|
27770
|
+
out = run_keel(only_commit, "--doctor").stdout
|
|
27771
|
+
for action in ("push", "release", "archive", "continuation", "issue"):
|
|
27772
|
+
if f"{action}: not authorized" not in out:
|
|
27773
|
+
report(
|
|
27774
|
+
f"{label}: {action} is not reported as unauthorized in a "
|
|
27775
|
+
"repository that declared only commit."
|
|
27776
|
+
)
|
|
27777
|
+
report(out)
|
|
27778
|
+
return 1
|
|
27779
|
+
|
|
27780
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
27781
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
27782
|
+
return 1
|
|
27783
|
+
report(f"{label} scenario passed.")
|
|
27784
|
+
return 0
|
|
27785
|
+
|
|
27786
|
+
|
|
27629
27787
|
SCENARIOS: tuple = (
|
|
27630
27788
|
("stateless-continuity", validate_stateless_continuity_scenario),
|
|
27631
27789
|
("core-gates", validate_core_gates_scenario),
|
|
@@ -27972,6 +28130,10 @@ SCENARIOS: tuple = (
|
|
|
27972
28130
|
"a-filter-drops-only-what-it-named",
|
|
27973
28131
|
validate_a_filter_drops_only_what_it_named_scenario,
|
|
27974
28132
|
),
|
|
28133
|
+
(
|
|
28134
|
+
"an-authorization-names-its-repository",
|
|
28135
|
+
validate_an_authorization_names_its_repository_scenario,
|
|
28136
|
+
),
|
|
27975
28137
|
)
|
|
27976
28138
|
|
|
27977
28139
|
|
package/src/core/config.js
CHANGED
|
@@ -13,8 +13,53 @@ const STANDING_AUTHORIZATION_ACTIONS = [
|
|
|
13
13
|
"release",
|
|
14
14
|
"archive",
|
|
15
15
|
"continuation",
|
|
16
|
+
"issue",
|
|
16
17
|
];
|
|
17
18
|
|
|
19
|
+
// The actions whose credential reaches further than the checkout the
|
|
20
|
+
// declaration sits in. Every other name here acts on this repository, so the
|
|
21
|
+
// declaration and the thing it permits are the same size; `gh` is account-wide,
|
|
22
|
+
// so a bare `issue` would silently be the widest entry in the file. Those
|
|
23
|
+
// actions are declared with the resource they may reach and refused bare —
|
|
24
|
+
// accepting the bare form as a convenience would make the narrow form optional
|
|
25
|
+
// and the wide one the default, which is the decision inverted.
|
|
26
|
+
const SCOPED_AUTHORIZATION_ACTIONS = new Set(["issue"]);
|
|
27
|
+
|
|
28
|
+
// `<owner>/<repo>`: two non-empty segments and nothing else. The shape is
|
|
29
|
+
// checked and the existence is not, for the reason `triage` never fetches an
|
|
30
|
+
// issue — a check that reaches the network trades the local, offline,
|
|
31
|
+
// deterministic evaluation its verdict rests on.
|
|
32
|
+
const AUTHORIZATION_SCOPE_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
33
|
+
|
|
34
|
+
// How the accepted names are spelled back to an author: the scoped ones carry
|
|
35
|
+
// their form, so the list is copyable rather than a name the next message
|
|
36
|
+
// refuses.
|
|
37
|
+
const STANDING_AUTHORIZATION_ACCEPTED_FORMS = STANDING_AUTHORIZATION_ACTIONS.map(
|
|
38
|
+
(action) =>
|
|
39
|
+
SCOPED_AUTHORIZATION_ACTIONS.has(action) ? `${action}:<owner>/<repo>` : action
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// One declared entry, split into the action and the resource it names. Returns
|
|
43
|
+
// the action and scope when the entry is usable, and otherwise why it is not,
|
|
44
|
+
// so the caller can say which of the three mistakes an author made.
|
|
45
|
+
function classifyAuthorizationEntry(entry) {
|
|
46
|
+
const separator = entry.indexOf(":");
|
|
47
|
+
const action = separator === -1 ? entry : entry.slice(0, separator);
|
|
48
|
+
const scope = separator === -1 ? null : entry.slice(separator + 1);
|
|
49
|
+
if (!STANDING_AUTHORIZATION_ACTIONS.includes(action)) {
|
|
50
|
+
return { ok: false, entry, reason: "unknown-action" };
|
|
51
|
+
}
|
|
52
|
+
if (!SCOPED_AUTHORIZATION_ACTIONS.has(action)) {
|
|
53
|
+
if (scope !== null) return { ok: false, entry, action, reason: "unexpected-scope" };
|
|
54
|
+
return { ok: true, entry, action, scope: null };
|
|
55
|
+
}
|
|
56
|
+
if (scope === null) return { ok: false, entry, action, reason: "missing-scope" };
|
|
57
|
+
if (!AUTHORIZATION_SCOPE_PATTERN.test(scope)) {
|
|
58
|
+
return { ok: false, entry, action, reason: "malformed-scope" };
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, entry, action, scope };
|
|
61
|
+
}
|
|
62
|
+
|
|
18
63
|
// The closed vocabulary of capability tiers a repository may declare for a
|
|
19
64
|
// delegated task. The names describe the capability the work requires, never
|
|
20
65
|
// the size of the work: a tier named for size would authorize the agent's guess
|
|
@@ -162,24 +207,64 @@ function readTriagePolicy(repo) {
|
|
|
162
207
|
// both — but only `archive` is a name this vocabulary accepts (#93). Naming
|
|
163
208
|
// that confusion only when `sync` is the entry present keeps every other
|
|
164
209
|
// unrecognized name (a genuine typo) unchanged.
|
|
165
|
-
function standingAuthorizationUnknownMessage(unknown) {
|
|
210
|
+
function standingAuthorizationUnknownMessage(unknown, problems = []) {
|
|
166
211
|
const base = `keel/config.yaml declares unrecognized ${
|
|
167
212
|
unknown.length === 1 ? "action" : "actions"
|
|
168
213
|
}: ${unknown.join(", ")}; accepted names are `
|
|
169
|
-
+ `${
|
|
214
|
+
+ `${STANDING_AUTHORIZATION_ACCEPTED_FORMS.join(", ")}. The whole declaration `
|
|
170
215
|
+ "authorizes nothing until it is corrected.";
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
216
|
+
const notes = [];
|
|
217
|
+
if (unknown.includes("sync")) {
|
|
218
|
+
notes.push(
|
|
219
|
+
"`sync` is a value of `change-close --action`, not a name `authorize:` "
|
|
220
|
+
+ "accepts; declare `archive` if you mean to authorize the gate that "
|
|
221
|
+
+ "runs it."
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
// A scoped action refused for its scope is not a typo, and saying "accepted
|
|
225
|
+
// names are ... issue" beside "unrecognized action: issue" would contradict
|
|
226
|
+
// itself. Name what is missing instead, and why this one name carries it.
|
|
227
|
+
for (const problem of problems) {
|
|
228
|
+
if (problem.reason === "missing-scope") {
|
|
229
|
+
notes.push(
|
|
230
|
+
`\`${problem.action}\` names no repository; write it as `
|
|
231
|
+
+ `\`${problem.action}:<owner>/<repo>\`. The credentials that open an `
|
|
232
|
+
+ "issue are account-wide, so an unscoped grant would reach every "
|
|
233
|
+
+ "repository the account can touch — wider than commit or push, "
|
|
234
|
+
+ "which this checkout bounds."
|
|
235
|
+
);
|
|
236
|
+
} else if (problem.reason === "malformed-scope") {
|
|
237
|
+
notes.push(
|
|
238
|
+
`\`${problem.entry}\` does not name a repository as `
|
|
239
|
+
+ `\`<owner>/<repo>\` — two non-empty segments and nothing else.`
|
|
240
|
+
);
|
|
241
|
+
} else if (problem.reason === "unexpected-scope") {
|
|
242
|
+
notes.push(
|
|
243
|
+
`\`${problem.action}\` takes no scope; it acts on this checkout, which `
|
|
244
|
+
+ "already bounds it."
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return notes.length === 0 ? base : `${base} ${notes.join(" ")}`;
|
|
175
249
|
}
|
|
176
250
|
|
|
177
251
|
function readStandingAuthorization(repo) {
|
|
178
252
|
const declared = [];
|
|
179
253
|
const unknown = [];
|
|
254
|
+
const problems = [];
|
|
255
|
+
// Action -> the resource it names, or null for an action the checkout bounds.
|
|
256
|
+
// Additive: `declared` stays the entries as written, so the capsule's
|
|
257
|
+
// inherited autonomy line reads back what the file says.
|
|
258
|
+
const scopes = new Map();
|
|
180
259
|
for (const entry of configList(repo, "authorize")) {
|
|
181
|
-
|
|
182
|
-
|
|
260
|
+
const classified = classifyAuthorizationEntry(entry);
|
|
261
|
+
if (classified.ok) {
|
|
262
|
+
declared.push(entry);
|
|
263
|
+
scopes.set(classified.action, classified.scope);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
unknown.push(entry);
|
|
267
|
+
if (classified.reason !== "unknown-action") problems.push(classified);
|
|
183
268
|
}
|
|
184
269
|
// Fail closed. A declaration Keel cannot fully read authorizes nothing,
|
|
185
270
|
// because the alternative is granting the entries beside a typo while the
|
|
@@ -187,11 +272,12 @@ function readStandingAuthorization(repo) {
|
|
|
187
272
|
if (unknown.length > 0) {
|
|
188
273
|
return {
|
|
189
274
|
declared: [],
|
|
275
|
+
scopes: new Map(),
|
|
190
276
|
unknown,
|
|
191
|
-
message: standingAuthorizationUnknownMessage(unknown),
|
|
277
|
+
message: standingAuthorizationUnknownMessage(unknown, problems),
|
|
192
278
|
};
|
|
193
279
|
}
|
|
194
|
-
return { declared, unknown, message: null };
|
|
280
|
+
return { declared, scopes, unknown, message: null };
|
|
195
281
|
}
|
|
196
282
|
|
|
197
283
|
// A nested block of `name: value` entries under one top-level key. Delegation
|
|
@@ -389,6 +475,7 @@ module.exports = {
|
|
|
389
475
|
CONFIG_RELATIVE_PATH,
|
|
390
476
|
DELEGATION_TIERS,
|
|
391
477
|
STANDING_AUTHORIZATION_ACTIONS,
|
|
478
|
+
SCOPED_AUTHORIZATION_ACTIONS,
|
|
392
479
|
readDelegationPolicy,
|
|
393
480
|
readPrecedentStore,
|
|
394
481
|
readStandingAuthorization,
|