@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 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.61.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.61.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.61.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.61.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",
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
38
38
  "scripts/validate_plugin.py",
39
39
  ]
40
40
 
41
- PACKAGE_VERSION = "5.61.0"
42
- PROTOCOL_VERSION = "5.61.0"
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 five names above are the whole vocabulary.",
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 five-name "
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
 
@@ -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,