@christang/keel 5.60.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 +390 -43
- 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",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import contextlib
|
|
6
7
|
import json
|
|
7
8
|
import hashlib
|
|
8
9
|
import os
|
|
@@ -37,8 +38,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
38
|
"scripts/validate_plugin.py",
|
|
38
39
|
]
|
|
39
40
|
|
|
40
|
-
PACKAGE_VERSION = "5.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
41
|
+
PACKAGE_VERSION = "5.62.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.62.0"
|
|
42
43
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
44
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
45
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -5521,36 +5522,46 @@ def validate_a_declared_dependency_is_resolved_scenario() -> int:
|
|
|
5521
5522
|
"""
|
|
5522
5523
|
label = "a-declared-dependency-is-resolved"
|
|
5523
5524
|
|
|
5524
|
-
# `no openspec on PATH`, and nothing else removed
|
|
5525
|
-
# would also remove `node`, and the
|
|
5526
|
-
# would
|
|
5527
|
-
without
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
if
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5525
|
+
# `no openspec on PATH`, and nothing else removed — by file, not by
|
|
5526
|
+
# directory. Removing the directory would also remove `node`, and the
|
|
5527
|
+
# openspec shim needs it, so the scenario would be asserting that a shell
|
|
5528
|
+
# without an interpreter fails (issue #137).
|
|
5529
|
+
with path_without_openspec() as sanitized:
|
|
5530
|
+
without = dict(os.environ)
|
|
5531
|
+
without["PATH"] = sanitized
|
|
5532
|
+
if shutil.which("openspec", path=without["PATH"]) is not None:
|
|
5533
|
+
report(
|
|
5534
|
+
f"{label}: could not build a PATH without openspec on it, so the "
|
|
5535
|
+
"reproduction cannot be set up."
|
|
5536
|
+
)
|
|
5537
|
+
return 1
|
|
5538
|
+
# The scenario's own precondition, asserted before the behavior. A shim
|
|
5539
|
+
# with no interpreter exits 127, and calling that a fact about openspec
|
|
5540
|
+
# sends the reader to a tool with nothing wrong with it.
|
|
5541
|
+
if shutil.which("node", path=without["PATH"]) is None:
|
|
5542
|
+
return skip_scenario(
|
|
5543
|
+
label,
|
|
5544
|
+
"node does not resolve on the PATH this scenario built, so the "
|
|
5545
|
+
"openspec shim it resolves would have no interpreter. That is a "
|
|
5546
|
+
"broken fixture, not a fact about openspec.",
|
|
5547
|
+
)
|
|
5548
|
+
resolved = run_openspec(ROOT, "--version", env=without)
|
|
5549
|
+
if resolved is None:
|
|
5550
|
+
report(
|
|
5551
|
+
f"{label}: with no `openspec` on PATH the runner resolved "
|
|
5552
|
+
"nothing, but this package declares it as a dependency and "
|
|
5553
|
+
"installs it at node_modules/.bin. A tool the repository ships "
|
|
5554
|
+
"is not a tool the host has to provide."
|
|
5555
|
+
)
|
|
5556
|
+
return 1
|
|
5557
|
+
if resolved.returncode != 0 or not re.search(
|
|
5558
|
+
r"\d+\.\d+\.\d+", resolved.stdout
|
|
5559
|
+
):
|
|
5560
|
+
report(
|
|
5561
|
+
f"{label}: the resolved openspec did not report a version. "
|
|
5562
|
+
f"exit={resolved.returncode} out={(resolved.stdout or '').strip()!r}"
|
|
5563
|
+
)
|
|
5564
|
+
return 1
|
|
5554
5565
|
|
|
5555
5566
|
# And it is the declared one, not whatever a host happens to carry.
|
|
5556
5567
|
declared = ROOT / "node_modules" / ".bin" / "openspec"
|
|
@@ -14043,6 +14054,60 @@ OPENSPEC_SEARCH_ORDER = (
|
|
|
14043
14054
|
)
|
|
14044
14055
|
|
|
14045
14056
|
|
|
14057
|
+
@contextlib.contextmanager
|
|
14058
|
+
def path_without_openspec(path: str | None = None):
|
|
14059
|
+
"""Yield `path` with every `openspec` executable on it removed, and nothing else.
|
|
14060
|
+
|
|
14061
|
+
Dropping a PATH *directory* because it holds an `openspec` also drops that
|
|
14062
|
+
directory's other contents. On the ordinary Homebrew plus `npm install -g`
|
|
14063
|
+
layout one directory holds both `openspec` and `node`, so the filter took the
|
|
14064
|
+
interpreter the openspec shim needs and the scenario built on it asserted that
|
|
14065
|
+
a shell without an interpreter fails — reported against `openspec`, which was
|
|
14066
|
+
installed and working (issue #137).
|
|
14067
|
+
|
|
14068
|
+
So a directory that holds the tool is replaced in place, at the same index, by
|
|
14069
|
+
a mirror symlinking every one of its entries except the `openspec` ones; a
|
|
14070
|
+
directory that does not hold it is passed through untouched. Exclusion is by
|
|
14071
|
+
stem, so `openspec.cmd` and its siblings cannot survive a filter whose whole
|
|
14072
|
+
purpose is that the name does not resolve.
|
|
14073
|
+
|
|
14074
|
+
The mirrors live for the duration of the context, so callers must hold it open
|
|
14075
|
+
across every child process they run against the PATH, not only across the call
|
|
14076
|
+
that built it.
|
|
14077
|
+
"""
|
|
14078
|
+
original = os.environ.get("PATH", "") if path is None else path
|
|
14079
|
+
entries = [entry for entry in original.split(os.pathsep) if entry]
|
|
14080
|
+
with tempfile.TemporaryDirectory(prefix="keel-no-openspec-") as raw:
|
|
14081
|
+
mirrors = Path(raw)
|
|
14082
|
+
rebuilt: list[str] = []
|
|
14083
|
+
for index, entry in enumerate(entries):
|
|
14084
|
+
source = Path(entry)
|
|
14085
|
+
try:
|
|
14086
|
+
contents = sorted(source.iterdir())
|
|
14087
|
+
except OSError:
|
|
14088
|
+
# Unreadable or absent: it carried nothing resolvable either way.
|
|
14089
|
+
rebuilt.append(entry)
|
|
14090
|
+
continue
|
|
14091
|
+
if not any(item.stem == "openspec" for item in contents):
|
|
14092
|
+
rebuilt.append(entry)
|
|
14093
|
+
continue
|
|
14094
|
+
mirror = mirrors / str(index)
|
|
14095
|
+
try:
|
|
14096
|
+
mirror.mkdir()
|
|
14097
|
+
for item in contents:
|
|
14098
|
+
if item.stem == "openspec":
|
|
14099
|
+
continue
|
|
14100
|
+
(mirror / item.name).symlink_to(item)
|
|
14101
|
+
except OSError:
|
|
14102
|
+
# Windows can refuse a symlink without the privilege. Fall back to
|
|
14103
|
+
# dropping the directory, which is what this replaces; the caller's
|
|
14104
|
+
# precondition check then reports the consequence by name instead
|
|
14105
|
+
# of attributing it to the tool.
|
|
14106
|
+
continue
|
|
14107
|
+
rebuilt.append(str(mirror))
|
|
14108
|
+
yield os.pathsep.join(rebuilt)
|
|
14109
|
+
|
|
14110
|
+
|
|
14046
14111
|
def resolve_openspec(env: dict[str, str] | None = None) -> str | None:
|
|
14047
14112
|
local = ROOT / "node_modules" / ".bin" / "openspec"
|
|
14048
14113
|
if local.is_file():
|
|
@@ -19743,19 +19808,20 @@ def validate_continuation_docs_scenario() -> int:
|
|
|
19743
19808
|
|
|
19744
19809
|
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
19745
19810
|
for needle in (
|
|
19746
|
-
"accepted names: commit, push, release, archive, continuation"
|
|
19811
|
+
"accepted names: commit, push, release, archive, continuation, "
|
|
19812
|
+
"issue:<owner>/<repo>",
|
|
19747
19813
|
"next unchecked task of the same change",
|
|
19748
19814
|
"the stop that re-asks for an approval already given",
|
|
19749
|
-
"The
|
|
19815
|
+
"The six names above are the whole vocabulary.",
|
|
19750
19816
|
):
|
|
19751
19817
|
if needle not in readme:
|
|
19752
19818
|
report(f"{label}: README.md lacks: {needle}")
|
|
19753
19819
|
return 1
|
|
19754
19820
|
|
|
19755
19821
|
config_text = (ROOT / "keel/config.yaml").read_text(encoding="utf-8")
|
|
19756
|
-
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:
|
|
19757
19823
|
report(
|
|
19758
|
-
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 "
|
|
19759
19825
|
"vocabulary."
|
|
19760
19826
|
)
|
|
19761
19827
|
return 1
|
|
@@ -24659,14 +24725,13 @@ def validate_dependency_resolves_where_npm_put_it_scenario() -> int:
|
|
|
24659
24725
|
shutil.copy2(ROOT / "package.json", package_root / "package.json")
|
|
24660
24726
|
|
|
24661
24727
|
# PATH without any openspec, so what resolves came from the layout and not
|
|
24662
|
-
# from the machine running the suite.
|
|
24728
|
+
# from the machine running the suite. Removed by file rather than by
|
|
24729
|
+
# directory: one directory holds `openspec` and `node` on the ordinary
|
|
24730
|
+
# Homebrew plus `npm install -g` layout, and the children below are run by
|
|
24731
|
+
# `node` (issue #137).
|
|
24663
24732
|
def clean_env(extra_path: Path | None = None) -> dict[str, str]:
|
|
24664
24733
|
env = dict(os.environ)
|
|
24665
|
-
entries = [
|
|
24666
|
-
entry
|
|
24667
|
-
for entry in env.get("PATH", "").split(os.pathsep)
|
|
24668
|
-
if entry and not (Path(entry) / "openspec").exists()
|
|
24669
|
-
]
|
|
24734
|
+
entries = [entry for entry in sanitized_path.split(os.pathsep) if entry]
|
|
24670
24735
|
if extra_path is not None:
|
|
24671
24736
|
entries.insert(0, str(extra_path))
|
|
24672
24737
|
env["PATH"] = os.pathsep.join(entries)
|
|
@@ -24696,7 +24761,21 @@ def validate_dependency_resolves_where_npm_put_it_scenario() -> int:
|
|
|
24696
24761
|
"",
|
|
24697
24762
|
)
|
|
24698
24763
|
|
|
24699
|
-
|
|
24764
|
+
# The mirrors live for the duration of the context, so it stays open across
|
|
24765
|
+
# every child process below rather than only across the call that built it.
|
|
24766
|
+
with path_without_openspec() as sanitized_path, tempfile.TemporaryDirectory(
|
|
24767
|
+
prefix="keel-hoisted-"
|
|
24768
|
+
) as raw:
|
|
24769
|
+
# The scenario's own precondition, asserted before the behavior: every
|
|
24770
|
+
# assertion below runs `node`, so a PATH without one is a broken fixture
|
|
24771
|
+
# and not a fact about where the dependency resolves.
|
|
24772
|
+
if shutil.which("node", path=sanitized_path) is None:
|
|
24773
|
+
return skip_scenario(
|
|
24774
|
+
label,
|
|
24775
|
+
"node does not resolve on the PATH this scenario built, and "
|
|
24776
|
+
"every assertion below runs the installed Keel through it. That "
|
|
24777
|
+
"is a broken fixture, not a fact about openspec.",
|
|
24778
|
+
)
|
|
24700
24779
|
root = Path(raw)
|
|
24701
24780
|
|
|
24702
24781
|
# The layout npm actually produces: Keel unpacked under the consumer's
|
|
@@ -27445,6 +27524,266 @@ def validate_authored_scenario_names_scenario() -> int:
|
|
|
27445
27524
|
return 0
|
|
27446
27525
|
|
|
27447
27526
|
|
|
27527
|
+
def validate_a_filter_drops_only_what_it_named_scenario() -> int:
|
|
27528
|
+
"""Issue #137: a PATH built to exclude one tool removed a whole directory.
|
|
27529
|
+
|
|
27530
|
+
Both scenarios that need "no openspec on PATH" dropped every directory that
|
|
27531
|
+
held an `openspec`. On the ordinary Homebrew plus `npm install -g` layout one
|
|
27532
|
+
directory holds `openspec` and `node`, so the filter took the interpreter the
|
|
27533
|
+
openspec shim needs, and the diagnostic named `openspec` — a tool that was
|
|
27534
|
+
installed and working.
|
|
27535
|
+
|
|
27536
|
+
The defect is a layout, so the fixture builds the layout instead of relying on
|
|
27537
|
+
this host having it: a directory carrying both binaries, on a PATH between two
|
|
27538
|
+
that carry neither. Asserted from both sides — that the runtime survived is the
|
|
27539
|
+
regression guard, that the tool is gone is the positive control, because a
|
|
27540
|
+
mirror that produced an empty directory would satisfy the second alone.
|
|
27541
|
+
"""
|
|
27542
|
+
label = "a-filter-drops-only-what-it-named"
|
|
27543
|
+
|
|
27544
|
+
def write_executable(path: Path, body: str) -> None:
|
|
27545
|
+
path.write_text(body, encoding="utf-8")
|
|
27546
|
+
path.chmod(0o755)
|
|
27547
|
+
|
|
27548
|
+
with tempfile.TemporaryDirectory(prefix="keel-shared-bin-") as raw:
|
|
27549
|
+
root = Path(raw)
|
|
27550
|
+
before = root / "before"
|
|
27551
|
+
shared = root / "shared"
|
|
27552
|
+
after = root / "after"
|
|
27553
|
+
for directory in (before, shared, after):
|
|
27554
|
+
directory.mkdir()
|
|
27555
|
+
write_executable(before / "tool-a", "#!/bin/sh\necho a\n")
|
|
27556
|
+
write_executable(after / "tool-b", "#!/bin/sh\necho b\n")
|
|
27557
|
+
# The layout under test: the interpreter and the tool in one directory,
|
|
27558
|
+
# plus a neighbour that belongs to neither and must survive with it.
|
|
27559
|
+
write_executable(shared / "node", "#!/bin/sh\necho node\n")
|
|
27560
|
+
write_executable(shared / "openspec", "#!/bin/sh\necho 1.12.0\n")
|
|
27561
|
+
write_executable(shared / "openspec.cmd", "#!/bin/sh\necho 1.12.0\n")
|
|
27562
|
+
write_executable(shared / "unrelated", "#!/bin/sh\necho unrelated\n")
|
|
27563
|
+
|
|
27564
|
+
source = os.pathsep.join(str(entry) for entry in (before, shared, after))
|
|
27565
|
+
with path_without_openspec(source) as sanitized:
|
|
27566
|
+
entries = sanitized.split(os.pathsep)
|
|
27567
|
+
|
|
27568
|
+
# Behavior before shape: what the defect destroys is the ability to
|
|
27569
|
+
# run the interpreter, and an entry count is only its symptom.
|
|
27570
|
+
node = shutil.which("node", path=sanitized)
|
|
27571
|
+
if node is None:
|
|
27572
|
+
report(
|
|
27573
|
+
f"{label}: node did not survive a filter that was removing "
|
|
27574
|
+
f"openspec. The interpreter shares a directory with the "
|
|
27575
|
+
f"tool, and the whole directory went. sanitized PATH "
|
|
27576
|
+
f"{sanitized!r}"
|
|
27577
|
+
)
|
|
27578
|
+
return 1
|
|
27579
|
+
if shutil.which("unrelated", path=sanitized) is None:
|
|
27580
|
+
report(
|
|
27581
|
+
f"{label}: an executable unrelated to openspec was removed "
|
|
27582
|
+
f"along with it."
|
|
27583
|
+
)
|
|
27584
|
+
return 1
|
|
27585
|
+
|
|
27586
|
+
# The positive control. Without it, an empty mirror passes.
|
|
27587
|
+
for name in ("openspec", "openspec.cmd"):
|
|
27588
|
+
found = shutil.which(name, path=sanitized)
|
|
27589
|
+
if found is not None:
|
|
27590
|
+
report(
|
|
27591
|
+
f"{label}: {name} still resolves on a PATH built to "
|
|
27592
|
+
f"exclude it, at {found!r}."
|
|
27593
|
+
)
|
|
27594
|
+
return 1
|
|
27595
|
+
|
|
27596
|
+
if len(entries) != 3:
|
|
27597
|
+
report(
|
|
27598
|
+
f"{label}: the sanitized PATH has {len(entries)} entries, "
|
|
27599
|
+
f"not the 3 it was given, so search order did not survive. "
|
|
27600
|
+
f"got {sanitized!r}"
|
|
27601
|
+
)
|
|
27602
|
+
return 1
|
|
27603
|
+
if Path(node).parent != Path(entries[1]):
|
|
27604
|
+
report(
|
|
27605
|
+
f"{label}: node survived but moved search position; it "
|
|
27606
|
+
f"resolved from {str(Path(node).parent)!r}, not from PATH "
|
|
27607
|
+
f"entry 1 {entries[1]!r}."
|
|
27608
|
+
)
|
|
27609
|
+
return 1
|
|
27610
|
+
|
|
27611
|
+
# M2: an entry that does not hold the tool is passed through, not
|
|
27612
|
+
# mirrored — the cost and the blast radius stay on the directories
|
|
27613
|
+
# that carry it.
|
|
27614
|
+
for index, original in ((0, before), (2, after)):
|
|
27615
|
+
if entries[index] != str(original):
|
|
27616
|
+
report(
|
|
27617
|
+
f"{label}: a PATH entry holding no openspec was "
|
|
27618
|
+
f"rewritten; expected {str(original)!r}, got "
|
|
27619
|
+
f"{entries[index]!r}."
|
|
27620
|
+
)
|
|
27621
|
+
return 1
|
|
27622
|
+
|
|
27623
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
27624
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
27625
|
+
return 1
|
|
27626
|
+
report(f"{label} scenario passed.")
|
|
27627
|
+
return 0
|
|
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
|
+
|
|
27448
27787
|
SCENARIOS: tuple = (
|
|
27449
27788
|
("stateless-continuity", validate_stateless_continuity_scenario),
|
|
27450
27789
|
("core-gates", validate_core_gates_scenario),
|
|
@@ -27787,6 +28126,14 @@ SCENARIOS: tuple = (
|
|
|
27787
28126
|
"change-verify-deferred-evidence",
|
|
27788
28127
|
validate_change_verify_deferred_evidence_scenario,
|
|
27789
28128
|
),
|
|
28129
|
+
(
|
|
28130
|
+
"a-filter-drops-only-what-it-named",
|
|
28131
|
+
validate_a_filter_drops_only_what_it_named_scenario,
|
|
28132
|
+
),
|
|
28133
|
+
(
|
|
28134
|
+
"an-authorization-names-its-repository",
|
|
28135
|
+
validate_an_authorization_names_its_repository_scenario,
|
|
28136
|
+
),
|
|
27790
28137
|
)
|
|
27791
28138
|
|
|
27792
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,
|