@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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.3.
|
|
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.
|
|
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.
|
|
9
|
-
//
|
|
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.
|
|
41
|
-
PROTOCOL_VERSION = "5.3.
|
|
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",
|