@christang/keel 5.3.8 → 5.3.9

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.
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.3.8 -->
1
+ <!-- keel:start version=5.3.9 -->
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/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.3.8",
5
+ "version": "5.3.9",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.3.8",
3
+ "version": "5.3.9",
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.8",
3
+ "version": "5.3.9",
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",
@@ -5,8 +5,10 @@
5
5
  // file edits while an explicit keel/guard.json manifest is active. Absence of
6
6
  // the manifest allows everything silently; a present-but-untrusted manifest
7
7
  // fails closed. The hook never writes state, never spawns the keel CLI, and
8
- // always exits 0 — denial is expressed only through hook output. Paths that
9
- // resolve outside the repository root are not product writes and pass through.
8
+ // always exits 0 — denial is expressed only through hook output. The
9
+ // repository is the guard's scope: a path resolving outside it is not a product
10
+ // write and passes through, decided before the manifest is read so that no
11
+ // manifest state can reach it.
10
12
 
11
13
  const crypto = require("crypto");
12
14
  const fs = require("fs");
@@ -116,12 +118,27 @@ function main() {
116
118
  }
117
119
  const repo =
118
120
  typeof event.cwd === "string" && event.cwd ? event.cwd : process.cwd();
119
- const manifestPath = path.join(repo, "keel", "guard.json");
120
- if (!fs.existsSync(manifestPath)) return 0;
121
121
 
122
122
  const pathField = FILE_EDIT_TOOLS.get(event.tool_name);
123
123
  if (!pathField) return 0;
124
124
 
125
+ // The repository is the guard's scope, so this boundary is settled before the
126
+ // manifest is read at all. It needs only the event's cwd and target, so no
127
+ // manifest state — absent, invalid, drifted, or completed — has anything to
128
+ // say about a path outside it. Deciding it here rather than further down is
129
+ // what stops a branch added to the manifest section from denying a file the
130
+ // guard never protected; that ordering has already failed twice.
131
+ const target = event.tool_input ? event.tool_input[pathField] : null;
132
+ if (typeof target !== "string" || !target) return 0;
133
+ const relative = path.relative(repo, path.resolve(repo, target));
134
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
135
+ return 0;
136
+ }
137
+ const candidate = relative.replace(/\\/g, "/");
138
+
139
+ const manifestPath = path.join(repo, "keel", "guard.json");
140
+ if (!fs.existsSync(manifestPath)) return 0;
141
+
125
142
  let manifest = null;
126
143
  try {
127
144
  manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
@@ -144,14 +161,6 @@ function main() {
144
161
  // stop the one thing the completion gate is waiting for. Derived from the
145
162
  // manifest's existing `change` field, so no manifest shape changes.
146
163
  const recordPrefix = `openspec/changes/${manifest.change}/`;
147
-
148
- const target = event.tool_input ? event.tool_input[pathField] : null;
149
- if (typeof target !== "string" || !target) return 0;
150
- const relative = path.relative(repo, path.resolve(repo, target));
151
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
152
- return 0;
153
- }
154
- const candidate = relative.replace(/\\/g, "/");
155
164
  if (candidate.startsWith(recordPrefix)) return 0;
156
165
 
157
166
  for (const entry of manifest.authority) {
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.3.8"
41
- PROTOCOL_VERSION = "5.3.8"
40
+ PACKAGE_VERSION = "5.3.9"
41
+ PROTOCOL_VERSION = "5.3.9"
42
42
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
43
43
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
44
44
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -5132,6 +5132,164 @@ def fill_template_slots(text: str, comments: str = "strip") -> str:
5132
5132
  text = collapsed
5133
5133
 
5134
5134
 
5135
+ def validate_guard_scope_is_the_repository_scenario() -> int:
5136
+ """Issue #31: a decision needing no manifest sat downstream of reading one.
5137
+
5138
+ Whether a target lies outside the repository is computable from the event's
5139
+ cwd and target path alone, but the invalid-manifest denial ran first, so a
5140
+ corrupt `keel/guard.json` denied writes to files the guard never protected.
5141
+ The precedence is what is asserted here, not the passthrough: a scenario
5142
+ checking only that an out-of-repo path passes under a valid manifest would
5143
+ have passed before this change too.
5144
+ """
5145
+ hook = ROOT / "plugins/keel/scripts/pretooluse-guard.js"
5146
+ if not hook.is_file():
5147
+ report(f"guard-scope-is-the-repository: missing hook {hook}.")
5148
+ return 1
5149
+
5150
+ with tempfile.TemporaryDirectory(prefix="keel-guard-scope-") as raw:
5151
+ root = Path(raw)
5152
+ repo = root / "repo"
5153
+ outside = root / "scratch"
5154
+ outside.mkdir(parents=True)
5155
+ write_text(repo / "src/feature.js", "// product\n")
5156
+ # A live spec, so the Covers source lands outside the record layer and
5157
+ # authority drift can actually fire. Drifting the change's own tasks.md
5158
+ # produces no drift at all, because the record layer exempts it.
5159
+ live = repo / "openspec/specs/demo-cap/spec.md"
5160
+ write_text(
5161
+ live,
5162
+ "# demo-cap\n\n## Purpose\n\nFixture.\n\n"
5163
+ "### Requirement: The system emits a feed status\n"
5164
+ "The system SHALL emit the recorded feed status.\n\n"
5165
+ "#### Scenario: A status is emitted\n"
5166
+ "- **WHEN** the feed runs\n- **THEN** the status is recorded\n",
5167
+ )
5168
+ write_text(
5169
+ repo / "openspec/changes/demo/tasks.md",
5170
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5171
+ "- [ ] 1.1 Exercise the guard\n"
5172
+ " - Covers:\n - demo-cap / The system emits a feed status\n"
5173
+ " - Touch:\n - src/feature.js\n"
5174
+ " - Verify:\n - Strategy: evidence-first\n - M1: node test.js\n"
5175
+ " - Evidence:\n - Contract: pending\n - M1: pending\n"
5176
+ " - Review:\n - Status: pending\n"
5177
+ " - Acceptance check: pending\n - Scope check: pending\n"
5178
+ " - Findings: pending\n - Blocker: none\n",
5179
+ )
5180
+ started = run_keel(
5181
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
5182
+ "--record", "--json",
5183
+ )
5184
+ if started.returncode != 0:
5185
+ report("guard-scope-is-the-repository: the fixture did not start.")
5186
+ report((started.stdout or started.stderr).strip())
5187
+ return 1
5188
+ manifest_path = repo / "keel/guard.json"
5189
+ manifest_text = manifest_path.read_text(encoding="utf-8")
5190
+ hashed = [
5191
+ item["path"] for item in json.loads(manifest_text).get("authority", [])
5192
+ ]
5193
+ if "openspec/specs/demo-cap/spec.md" not in hashed:
5194
+ report(
5195
+ "guard-scope-is-the-repository: the fixture hashed no authority "
5196
+ f"outside the change directory, so drift cannot fire: {hashed}."
5197
+ )
5198
+ return 1
5199
+
5200
+ def decide(target: Path) -> str:
5201
+ event = json.dumps({
5202
+ "cwd": str(repo),
5203
+ "tool_name": "Edit",
5204
+ "tool_input": {"file_path": str(target)},
5205
+ })
5206
+ result = subprocess.run(
5207
+ ["node", str(hook)],
5208
+ input=event,
5209
+ capture_output=True,
5210
+ text=True,
5211
+ encoding="utf-8",
5212
+ errors="replace",
5213
+ check=False,
5214
+ )
5215
+ out = (result.stdout or "").strip()
5216
+ if not out:
5217
+ return "allow"
5218
+ payload = json.loads(out)["hookSpecificOutput"]
5219
+ return payload.get("permissionDecisionReason", "deny")
5220
+
5221
+ def expect(label: str, target: Path, allow: bool, needle: str = "") -> bool:
5222
+ verdict = decide(target)
5223
+ if allow:
5224
+ if verdict != "allow":
5225
+ report(f"guard-scope-is-the-repository: {label} was denied.")
5226
+ report(f" {verdict}")
5227
+ return False
5228
+ return True
5229
+ if verdict == "allow":
5230
+ report(f"guard-scope-is-the-repository: {label} was allowed.")
5231
+ return False
5232
+ if needle and needle not in verdict:
5233
+ report(
5234
+ f"guard-scope-is-the-repository: {label} was denied for the "
5235
+ f"wrong reason; expected {needle!r}."
5236
+ )
5237
+ report(f" {verdict}")
5238
+ return False
5239
+ return True
5240
+
5241
+ scratch = outside / "notes.md"
5242
+ # M2 — the in-repository denials must survive the reordering.
5243
+ checks = [
5244
+ expect("an in-Touch write", repo / "src/feature.js", True),
5245
+ expect(
5246
+ "an in-repository path outside Touch",
5247
+ repo / "other.js",
5248
+ False,
5249
+ "outside Touch",
5250
+ ),
5251
+ expect(
5252
+ "the guarded change's own records",
5253
+ repo / "openspec/changes/demo/notes.md",
5254
+ True,
5255
+ ),
5256
+ expect("an out-of-repository write", scratch, True),
5257
+ ]
5258
+
5259
+ # M1, first half — genuine authority drift.
5260
+ write_text(live, live.read_text(encoding="utf-8") + "\nDRIFTED\n")
5261
+ checks.append(
5262
+ expect("an in-Touch write under drift", repo / "src/feature.js", False, "drift")
5263
+ )
5264
+ checks.append(
5265
+ expect("an out-of-repository write under drift", scratch, True)
5266
+ )
5267
+
5268
+ # M1, second half — the corrupt manifest, which is the reported defect.
5269
+ manifest_path.write_text("{ not json", encoding="utf-8")
5270
+ checks.append(
5271
+ expect("an out-of-repository write under a corrupt manifest", scratch, True)
5272
+ )
5273
+ checks.append(
5274
+ expect(
5275
+ "an in-repository write under a corrupt manifest",
5276
+ repo / "src/feature.js",
5277
+ False,
5278
+ "invalid",
5279
+ )
5280
+ )
5281
+ if not all(checks):
5282
+ return 1
5283
+ if "guard-scope-is-the-repository" not in {name for name, _ in SCENARIOS}:
5284
+ report(
5285
+ "guard-scope-is-the-repository: the scenario registry does not "
5286
+ "include it."
5287
+ )
5288
+ return 1
5289
+ report("guard-scope-is-the-repository scenario passed.")
5290
+ return 0
5291
+
5292
+
5135
5293
  def validate_completion_requires_a_recorded_anchor_scenario() -> int:
5136
5294
  """Issue #30: an unrecorded anchor made the drift guarantee conditional.
5137
5295
 
@@ -14145,6 +14303,10 @@ SCENARIOS: tuple = (
14145
14303
  "completion-requires-a-recorded-anchor",
14146
14304
  validate_completion_requires_a_recorded_anchor_scenario,
14147
14305
  ),
14306
+ (
14307
+ "guard-scope-is-the-repository",
14308
+ validate_guard_scope_is_the_repository_scenario,
14309
+ ),
14148
14310
  ("spec-template-validates", validate_spec_template_validates_scenario),
14149
14311
  (
14150
14312
  "tasks-template-red-green-example",