@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 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 five names above are the whole vocabulary. An unrecognized entry is
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
 
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.60.0 -->
1
+ <!-- keel:start version=5.62.0 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
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
- declared.includes(action) ? "authorized" : "not authorized"
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
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.60.0",
5
+ "version": "5.62.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.60.0",
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.60.0",
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.60.0"
41
- PROTOCOL_VERSION = "5.60.0"
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. Emptying PATH outright
5525
- # would also remove `node`, and the openspec shim needs it — the scenario
5526
- # would then be asserting that a shell without an interpreter fails.
5527
- without = dict(os.environ)
5528
- without["PATH"] = os.pathsep.join(
5529
- entry
5530
- for entry in os.environ.get("PATH", "").split(os.pathsep)
5531
- if entry and not (Path(entry) / "openspec").exists()
5532
- )
5533
- if shutil.which("openspec", path=without["PATH"]) is not None:
5534
- report(
5535
- f"{label}: could not build a PATH without openspec on it, so the "
5536
- "reproduction cannot be set up."
5537
- )
5538
- return 1
5539
- resolved = run_openspec(ROOT, "--version", env=without)
5540
- if resolved is None:
5541
- report(
5542
- f"{label}: with no `openspec` on PATH the runner resolved nothing, "
5543
- "but this package declares it as a dependency and installs it at "
5544
- "node_modules/.bin. A tool the repository ships is not a tool the "
5545
- "host has to provide."
5546
- )
5547
- return 1
5548
- if resolved.returncode != 0 or not re.search(r"\d+\.\d+\.\d+", resolved.stdout):
5549
- report(
5550
- f"{label}: the resolved openspec did not report a version. "
5551
- f"exit={resolved.returncode} out={(resolved.stdout or '').strip()!r}"
5552
- )
5553
- return 1
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 five names above are the whole vocabulary.",
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 five-name "
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
- with tempfile.TemporaryDirectory(prefix="keel-hoisted-") as raw:
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
 
@@ -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
- + `${STANDING_AUTHORIZATION_ACTIONS.join(", ")}. The whole declaration `
214
+ + `${STANDING_AUTHORIZATION_ACCEPTED_FORMS.join(", ")}. The whole declaration `
170
215
  + "authorizes nothing until it is corrected.";
171
- if (!unknown.includes("sync")) return base;
172
- return `${base} \`sync\` is a value of \`change-close --action\`, not a `
173
- + "name `authorize:` accepts; declare `archive` if you mean to authorize "
174
- + "the gate that runs it.";
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
- if (STANDING_AUTHORIZATION_ACTIONS.includes(entry)) declared.push(entry);
182
- else unknown.push(entry);
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,