@peterxiaoyang/superspec 0.1.7 → 0.1.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/README.md +16 -1
- package/adapters/codex/install-map.json +5 -0
- package/bin/superspec-hook.js +4 -0
- package/dist/src/archive.js +2 -0
- package/dist/src/cli_args.d.ts +5 -0
- package/dist/src/cli_args.js +41 -0
- package/dist/src/core.js +36 -0
- package/dist/src/evidence.js +3 -0
- package/dist/src/gates.js +2 -2
- package/dist/src/hooks/adapter.d.ts +5 -0
- package/dist/src/hooks/adapter.js +311 -0
- package/dist/src/hooks/guard_api.d.ts +12 -0
- package/dist/src/hooks/guard_api.js +797 -0
- package/dist/src/hooks/health.d.ts +4 -0
- package/dist/src/hooks/health.js +98 -0
- package/dist/src/hooks/policy_event.d.ts +41 -0
- package/dist/src/hooks/policy_event.js +2624 -0
- package/dist/src/hooks/types.d.ts +72 -0
- package/dist/src/hooks/types.js +1 -0
- package/dist/src/hooks/validation.d.ts +3 -0
- package/dist/src/hooks/validation.js +70 -0
- package/dist/src/packet_measure.js +67 -102
- package/dist/superspec_hook.d.ts +4 -0
- package/dist/superspec_hook.js +35 -0
- package/package.json +7 -2
- package/schemas/hook-event.schema.json +27 -0
- package/templates/hooks/codex-hooks.json +61 -0
- package/templates/workflow/skills/superspec-apply/SKILL.md +7 -0
- package/templates/workflow/skills/superspec-archive/SKILL.md +8 -0
- package/templates/workflow/skills/superspec-explore/SKILL.md +7 -0
- package/templates/workflow/skills/superspec-propose/SKILL.md +7 -0
- package/templates/workflow/skills/superspec-review/SKILL.md +7 -0
package/README.md
CHANGED
|
@@ -82,6 +82,8 @@ superspec init --scope project
|
|
|
82
82
|
|
|
83
83
|
这条命令的意思是:把 SuperSpec 当前可用的工作流入口安装到项目里。
|
|
84
84
|
|
|
85
|
+
初始化还会安装托管的 `.codex/hooks.json`。它会让 Codex 在写文件前、工具执行后、子智能体启动和停止时调用 SuperSpec,做检查和记录。
|
|
86
|
+
|
|
85
87
|
Windows PowerShell 如果拦截 npm 的 `.ps1` 脚本,请改用:
|
|
86
88
|
|
|
87
89
|
```powershell
|
|
@@ -157,6 +159,18 @@ openspec/changes/<变更ID>/.superspec/
|
|
|
157
159
|
`.superspec/` 要不要提交到 git,由你的团队决定。
|
|
158
160
|
如果不提交,删掉后就没有 git 历史可以恢复。
|
|
159
161
|
|
|
162
|
+
## Hook 会做什么
|
|
163
|
+
|
|
164
|
+
SuperSpec 安装的 hook 会在几个关键时机运行:
|
|
165
|
+
|
|
166
|
+
- 写文件前:检查是否会改到 SuperSpec 的过程记录、提前归档、绕过任务检查,或写到当前任务不该写的地方
|
|
167
|
+
- 工具执行后:如果刚跑的是测试或验证命令,就记录这次结果
|
|
168
|
+
- 子智能体启动和停止时:记录这次子智能体运行的基本信息
|
|
169
|
+
|
|
170
|
+
这些 hook 的默认超时时间是 `120` 秒。这个时间限制的是 hook 自己的检查过程,不限制 `npm test`、构建命令或子智能体本身能运行多久。
|
|
171
|
+
|
|
172
|
+
hook 不是安全沙箱。它能减少误操作、拦住一部分明显会破坏流程记录的写入,并留下审计线索;但不能保证阻止所有绕过,也不能把记录变成不可伪造的安全证明。
|
|
173
|
+
|
|
160
174
|
## 重要边界
|
|
161
175
|
|
|
162
176
|
SuperSpec 能让流程更规范,但它不是安全锁。
|
|
@@ -175,7 +189,7 @@ SuperSpec 能让流程更规范,但它不是安全锁。
|
|
|
175
189
|
- 阻止恶意伪造记录
|
|
176
190
|
- 替代正式的安全审计、合规审计或法律证明
|
|
177
191
|
|
|
178
|
-
也就是说,SuperSpec
|
|
192
|
+
也就是说,SuperSpec 目前是“流程纪律 + 审计辅助工具”,不是“强制安全系统”。hook 会增强可见性和一部分写入检查,但它仍然不能替代正式的安全控制。
|
|
179
193
|
|
|
180
194
|
## 常用命令
|
|
181
195
|
|
|
@@ -223,6 +237,7 @@ superspec doctor
|
|
|
223
237
|
|
|
224
238
|
```text
|
|
225
239
|
.codex/
|
|
240
|
+
hooks.json
|
|
226
241
|
skills/superspec-explore/
|
|
227
242
|
skills/superspec-propose/
|
|
228
243
|
skills/superspec-apply/
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
"source": "templates/workflow/skills/superspec-archive/SKILL.md",
|
|
28
28
|
"target": ".codex/skills/superspec-archive/SKILL.md"
|
|
29
29
|
},
|
|
30
|
+
{
|
|
31
|
+
"kind": "hook",
|
|
32
|
+
"source": "templates/hooks/codex-hooks.json",
|
|
33
|
+
"target": ".codex/hooks.json"
|
|
34
|
+
},
|
|
30
35
|
{
|
|
31
36
|
"kind": "prompt",
|
|
32
37
|
"source": "templates/workflow/prompts/architect.md",
|
package/dist/src/archive.js
CHANGED
|
@@ -35,6 +35,8 @@ function write_text(path, text) {
|
|
|
35
35
|
function excluded_manifest_entry(relPath) {
|
|
36
36
|
if (relPath.endsWith(STATE_LOCK_FILENAME) || relPath.endsWith("superspec-state.tmp"))
|
|
37
37
|
return true;
|
|
38
|
+
if (relPath === ".superspec/hook-runtime" || relPath.startsWith(".superspec/hook-runtime/"))
|
|
39
|
+
return true;
|
|
38
40
|
if (relPath === ".superspec/artifacts/archive-preservation.json")
|
|
39
41
|
return true;
|
|
40
42
|
return false;
|
package/dist/src/cli_args.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ export type ParsedArgs = {
|
|
|
9
9
|
task_id?: string;
|
|
10
10
|
test_id?: string;
|
|
11
11
|
phase?: "red" | "characterization" | "green";
|
|
12
|
+
event_ref?: string;
|
|
13
|
+
workflow?: string;
|
|
14
|
+
entrypoint_token?: string;
|
|
15
|
+
end_reason?: string;
|
|
16
|
+
lifecycle_token?: string;
|
|
12
17
|
role?: string;
|
|
13
18
|
evidence_kind?: string;
|
|
14
19
|
round?: number;
|
package/dist/src/cli_args.js
CHANGED
|
@@ -11,6 +11,8 @@ const SIMPLE_COMMANDS = [
|
|
|
11
11
|
"check-verify-ready",
|
|
12
12
|
"check-archive-ready",
|
|
13
13
|
"check-archived",
|
|
14
|
+
"hook-health",
|
|
15
|
+
"hook-session-status",
|
|
14
16
|
];
|
|
15
17
|
const COMMANDS = [
|
|
16
18
|
"init",
|
|
@@ -27,6 +29,13 @@ const COMMANDS = [
|
|
|
27
29
|
"apply-code-review-packet",
|
|
28
30
|
"apply-verify-packet",
|
|
29
31
|
"ledger-render",
|
|
32
|
+
"hook-check-write",
|
|
33
|
+
"hook-check-command",
|
|
34
|
+
"hook-record-test",
|
|
35
|
+
"hook-record-subagent-start",
|
|
36
|
+
"hook-record-subagent-stop",
|
|
37
|
+
"hook-session-begin",
|
|
38
|
+
"hook-session-end",
|
|
30
39
|
];
|
|
31
40
|
const COMMAND_LIST = COMMANDS.join(",");
|
|
32
41
|
const COMMAND_CHOICES = COMMANDS.map((item) => `'${item}'`).join(", ");
|
|
@@ -61,6 +70,13 @@ function requiredValueFlags(command) {
|
|
|
61
70
|
flags.push("--task-id", "--executor-report-ref", "--task-code-review-report-ref", "--green-test-run-evidence-ref");
|
|
62
71
|
if (command === "ledger-render")
|
|
63
72
|
flags.push("--gate");
|
|
73
|
+
if (command === "hook-check-write" || command === "hook-check-command" || command === "hook-record-test"
|
|
74
|
+
|| command === "hook-record-subagent-start" || command === "hook-record-subagent-stop")
|
|
75
|
+
flags.push("--event-ref");
|
|
76
|
+
if (command === "hook-session-begin")
|
|
77
|
+
flags.push("--workflow", "--entrypoint-token");
|
|
78
|
+
if (command === "hook-session-end")
|
|
79
|
+
flags.push("--reason");
|
|
64
80
|
return flags;
|
|
65
81
|
}
|
|
66
82
|
function requiredBooleanFlags(command) {
|
|
@@ -84,6 +100,8 @@ function optionalValueFlags(command) {
|
|
|
84
100
|
return ["--red-test-run-evidence-ref", "--characterization-test-run-evidence-ref"];
|
|
85
101
|
if (command === "ledger-render")
|
|
86
102
|
return ["--round"];
|
|
103
|
+
if (command === "hook-session-end")
|
|
104
|
+
return ["--lifecycle-token"];
|
|
87
105
|
return [];
|
|
88
106
|
}
|
|
89
107
|
function formatUsage(command) {
|
|
@@ -376,6 +394,29 @@ export function parse_argv(argv) {
|
|
|
376
394
|
throw new GuardError("--round 必须是大于等于 1 的整数");
|
|
377
395
|
return { command, change, gate, round };
|
|
378
396
|
}
|
|
397
|
+
if (command === "hook-check-write" || command === "hook-check-command" || command === "hook-record-test"
|
|
398
|
+
|| command === "hook-record-subagent-start" || command === "hook-record-subagent-stop") {
|
|
399
|
+
const eventRef = getValue("--event-ref");
|
|
400
|
+
if (!eventRef)
|
|
401
|
+
throw new Error("缺少必填参数 --event-ref");
|
|
402
|
+
return { command, change, format, event_ref: eventRef };
|
|
403
|
+
}
|
|
404
|
+
if (command === "hook-session-begin") {
|
|
405
|
+
const workflow = getValue("--workflow");
|
|
406
|
+
const entrypointToken = getValue("--entrypoint-token");
|
|
407
|
+
if (!workflow)
|
|
408
|
+
throw new Error("缺少必填参数 --workflow");
|
|
409
|
+
if (!entrypointToken)
|
|
410
|
+
throw new Error("缺少必填参数 --entrypoint-token");
|
|
411
|
+
return { command, change, format, workflow, entrypoint_token: entrypointToken };
|
|
412
|
+
}
|
|
413
|
+
if (command === "hook-session-end") {
|
|
414
|
+
const endReason = getValue("--reason");
|
|
415
|
+
const lifecycleToken = getValue("--lifecycle-token");
|
|
416
|
+
if (!endReason)
|
|
417
|
+
throw new Error("缺少必填参数 --reason");
|
|
418
|
+
return { command, change, format, end_reason: endReason, lifecycle_token: lifecycleToken ?? "" };
|
|
419
|
+
}
|
|
379
420
|
if (command === "check-task-reopen" || command === "check-task-edit" || command === "check-task-complete") {
|
|
380
421
|
const taskId = getValue("--task-id");
|
|
381
422
|
if (!taskId)
|
package/dist/src/core.js
CHANGED
|
@@ -23,6 +23,7 @@ import { dirty_worktree_paths, dirty_worktree_reasons, dirty_write_scope_red_rea
|
|
|
23
23
|
import { append_ledger, load_state, compute_fingerprints, prepare_recomputed_state_write, read_ledger_text, record_supersede_ledger_events, restore_state_snapshot_locked, state_corrupt_reasons, state_file, state_file_corrupt, state_stale_reasons, force_unlock_state, with_state_lock, write_prepared_state_locked, } from "./state.js";
|
|
24
24
|
import { archive_manifest_path, begin_archive_preservation_bundle, check_archived, preset_upgrade_reasons, preset_upgrade_required_from_context, write_archive_preservation_bundle, } from "./archive.js";
|
|
25
25
|
import { check_archive_ready, check_apply_ready, check_artifact, check_init, check_superspec_gate, check_review_complete, check_review_ready, check_task_complete, check_task_edit, check_task_reopen, check_verify_complete, evidence_schema_guard, superspec_agent_reasons, superspec_workflow_skill_reasons, openspec_cli_capability_reasons, openspec_init_reasons, } from "./gates.js";
|
|
26
|
+
import { hookCheckCommand, hookCheckWrite, hookHealth, hookRecordSubagentStart, hookRecordSubagentStop, hookRecordTest, hookSessionBegin, hookSessionEnd, hookSessionStatus, } from "./hooks/guard_api.js";
|
|
26
27
|
const RETRY_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
27
28
|
const DEFAULT_MAX_STATE_WRITE_RETRIES = 5;
|
|
28
29
|
function sleep_ms(ms) {
|
|
@@ -42,6 +43,39 @@ export function load_context(change) {
|
|
|
42
43
|
const evidences = index_evidence(changeRoot);
|
|
43
44
|
return [status, repoRoot, changeRoot, evidences];
|
|
44
45
|
}
|
|
46
|
+
function is_hook_command(command) {
|
|
47
|
+
return command === "hook-check-write"
|
|
48
|
+
|| command === "hook-check-command"
|
|
49
|
+
|| command === "hook-record-test"
|
|
50
|
+
|| command === "hook-record-subagent-start"
|
|
51
|
+
|| command === "hook-record-subagent-stop"
|
|
52
|
+
|| command === "hook-health"
|
|
53
|
+
|| command === "hook-session-begin"
|
|
54
|
+
|| command === "hook-session-status"
|
|
55
|
+
|| command === "hook-session-end";
|
|
56
|
+
}
|
|
57
|
+
function dispatch_hook_command(args) {
|
|
58
|
+
const change = args.change;
|
|
59
|
+
if (args.command === "hook-check-write")
|
|
60
|
+
return [hookCheckWrite(change, args.event_ref ?? ""), "hook"];
|
|
61
|
+
if (args.command === "hook-check-command")
|
|
62
|
+
return [hookCheckCommand(change, args.event_ref ?? ""), "hook"];
|
|
63
|
+
if (args.command === "hook-record-test")
|
|
64
|
+
return [hookRecordTest(change, args.event_ref ?? ""), "hook"];
|
|
65
|
+
if (args.command === "hook-record-subagent-start")
|
|
66
|
+
return [hookRecordSubagentStart(change, args.event_ref ?? ""), "hook"];
|
|
67
|
+
if (args.command === "hook-record-subagent-stop")
|
|
68
|
+
return [hookRecordSubagentStop(change, args.event_ref ?? ""), "hook"];
|
|
69
|
+
if (args.command === "hook-health")
|
|
70
|
+
return [hookHealth(change), "hook"];
|
|
71
|
+
if (args.command === "hook-session-begin")
|
|
72
|
+
return [hookSessionBegin(change, args.workflow ?? "", args.entrypoint_token ?? ""), "hook"];
|
|
73
|
+
if (args.command === "hook-session-status")
|
|
74
|
+
return [hookSessionStatus(change), "hook"];
|
|
75
|
+
if (args.command === "hook-session-end")
|
|
76
|
+
return [hookSessionEnd(change, args.end_reason ?? "", args.lifecycle_token ?? ""), "hook"];
|
|
77
|
+
throw new GuardError(`unknown hook command: ${args.command}`);
|
|
78
|
+
}
|
|
45
79
|
export function cmd_status(change) {
|
|
46
80
|
const [status, repoRoot, changeRoot] = runtime.load_context(change);
|
|
47
81
|
const amap = artifact_status_map(status);
|
|
@@ -117,6 +151,8 @@ export function cmd_init_summary(change, changeRoot, decision) {
|
|
|
117
151
|
function dispatch_once(args) {
|
|
118
152
|
const change = args.change;
|
|
119
153
|
const cmd = args.command;
|
|
154
|
+
if (is_hook_command(cmd))
|
|
155
|
+
return dispatch_hook_command(args);
|
|
120
156
|
if (cmd === "check-archived")
|
|
121
157
|
return [check_archived(change, repo_root_from_cwd()), "archive"];
|
|
122
158
|
const [status, repoRoot, changeRoot, evidences] = runtime.load_context(change);
|
package/dist/src/evidence.js
CHANGED
|
@@ -5,6 +5,7 @@ import { normalize_gate } from "./openspec.js";
|
|
|
5
5
|
import { superspec_dir } from "./paths.js";
|
|
6
6
|
import { findings_schema_reasons, review_digest_schema_reasons, standing_authorization_schema_reasons, user_decision_schema_reasons, } from "./disclosure.js";
|
|
7
7
|
import { pinned_artifact_ref_reasons as shared_pinned_artifact_ref_reasons, worker_test_run_reasons, } from "./apply_worker_chain.js";
|
|
8
|
+
import { strictRoleRunlogReasons, strictRuntimeEvidenceReasons } from "./hooks/validation.js";
|
|
8
9
|
function file_ref_reasons(baseRoot, ev, field, code) {
|
|
9
10
|
const problems = [];
|
|
10
11
|
const raw = ev[field];
|
|
@@ -509,6 +510,7 @@ export function validate_evidence_schema(ev, change, changeRoot, repoRoot) {
|
|
|
509
510
|
problems.push(...human_confirmation_reasons(ev));
|
|
510
511
|
if (ev.kind === "test_run")
|
|
511
512
|
problems.push(...test_run_reasons(ev, changeRoot));
|
|
513
|
+
problems.push(...strictRuntimeEvidenceReasons(ev));
|
|
512
514
|
if (ev.kind === "apply_worker_chain")
|
|
513
515
|
problems.push(...apply_worker_chain_reasons(ev, changeRoot));
|
|
514
516
|
// DISC Phase 1: disclosure evidence kinds and reviewer findings[] are schema-checked fail-closed.
|
|
@@ -556,6 +558,7 @@ export function validate_evidence_schema(ev, change, changeRoot, repoRoot) {
|
|
|
556
558
|
const targetRoot = ev.kind === "source_guidance" ? repoRoot : changeRoot;
|
|
557
559
|
problems.push(...role_target_ref_reasons(ev, targetRoot));
|
|
558
560
|
problems.push(...output_ref_target_overlap_reasons(ev, changeRoot, targetRoot));
|
|
561
|
+
problems.push(...strictRoleRunlogReasons(changeRoot, ev));
|
|
559
562
|
}
|
|
560
563
|
if (ev.kind === "source_guidance") {
|
|
561
564
|
if (!REVIEW_GUIDANCE_ROLES.includes(String(ev.agent_role))) {
|
package/dist/src/gates.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join, relative } from "node:path";
|
|
|
3
3
|
import { ARTIFACT_ENTER_GATE, MAIN_ADJUDICATION_DECISIONS, REQUEST_CHANGES_ROUTES, NO_TDD_REASONS, OPENSPEC_ARTIFACTS, FINAL_VERIFICATION_ROLES, REQUIRED_SUPERSPEC_AGENT_ROLES, REQUIRED_SUPERSPEC_WORKFLOW_SKILLS, REVIEW_GUIDANCE_ROLES, REVIEW_EVIDENCE_REQUIRED_FIELDS, TDD_MODES, VERIFY_EVIDENCE_REQUIRED_FIELDS, allow, block, isObject, reason, renderList, repr, pinned_ref_key, safe_within, sha256_text, runtime, toPosix, } from "./util.js";
|
|
4
4
|
import { all_done, artifact_status_map, get_repo_root, is_done, normalize_gate, openspec_cli_probe } from "./openspec.js";
|
|
5
5
|
import { read_agent_toml_name, read_skill_frontmatter_name, sidecar_business_invariants_path, sidecar_discovery_path, sidecar_test_contract_path } from "./paths.js";
|
|
6
|
+
import { hookInitReasons } from "./hooks/health.js";
|
|
6
7
|
import { business_invariant_ids, business_invariant_validation_reasons, automated_hard_business_invariant_ids, evidence_invariant_refs, evidence_invariant_ref_reasons, evidence_test_contract_invariant_reasons, human_confirmation_business_invariant_ids, invariant_matrix_coverage_reasons, post_implementation_business_invariant_ids, red_green_invariant_ids, test_contract_invariant_ids, } from "./invariants.js";
|
|
7
8
|
import { evidence_test_id_reasons, declared_test_evidence_reasons, parse_spec_scenarios, parse_tasks, parse_test_contract_ids, parse_test_contract_records, red_green_test_ids, splitList, tasks_structure_hash, task_alternative_verification, task_test_evidence, task_test_refs, test_contract_covers_scenario, test_contract_invariant_refs_by_test, write_scope_conflict_reasons, } from "./tasks.js";
|
|
8
9
|
import { duplicate_evidence_id_reasons, dangling_evidence_ref_reasons, final_verification_evidences, live_task_reopens, live_task_reopen_resolutions, live_pass, live_user_confirmations, pass_task_reopens, supersede_reasons, unresolved_live_task_reopens, validate_evidence_schema, verify_reference_reasons, } from "./evidence.js";
|
|
@@ -427,8 +428,7 @@ export function check_init(change, status, repoRoot, changeRoot) {
|
|
|
427
428
|
if (!Array.isArray(status.applyRequires) || !new Set(status.applyRequires).has("tasks")) {
|
|
428
429
|
reasons.push(reason("unexpected_apply_requires", "OpenSpec applyRequires must include native tasks artifact"));
|
|
429
430
|
}
|
|
430
|
-
|
|
431
|
-
reasons.push(reason("v1_hook_artifact_present", ".codex/hooks.json belongs to superspec v2"));
|
|
431
|
+
reasons.push(...hookInitReasons(repoRoot));
|
|
432
432
|
if (existsSync(join(repoRoot, "openspec", "schemas", "superspec")))
|
|
433
433
|
reasons.push(reason("custom_superspec_schema_present", "openspec/schemas/superspec is not part of superspec v1 overlay"));
|
|
434
434
|
if (reasons.length > 0)
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { isObject } from "../util.js";
|
|
6
|
+
import { hookCheckCommand, hookCheckWrite, hookRecordSubagentStart, hookRecordSubagentStop, hookRecordTest, } from "./guard_api.js";
|
|
7
|
+
import { commandLooksLikeTestValidation, extractWriteIntent, isSuperSpecTrustRootPath } from "./policy_event.js";
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
let change = process.env.SUPERSPEC_CHANGE || null;
|
|
10
|
+
for (let idx = 0; idx < argv.length; idx += 1) {
|
|
11
|
+
if (argv[idx] === "--change")
|
|
12
|
+
change = argv[idx + 1] || null;
|
|
13
|
+
}
|
|
14
|
+
return { change };
|
|
15
|
+
}
|
|
16
|
+
function isDir(path) {
|
|
17
|
+
try {
|
|
18
|
+
return statSync(path).isDirectory();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function readJson(path) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
27
|
+
return isObject(parsed) ? parsed : null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function validReasonArray(value) {
|
|
34
|
+
return Array.isArray(value) && value.every((item) => (isObject(item)
|
|
35
|
+
&& typeof item.code === "string"
|
|
36
|
+
&& item.code.length > 0
|
|
37
|
+
&& typeof item.message === "string"
|
|
38
|
+
&& item.message.length > 0
|
|
39
|
+
&& Array.isArray(item.refs)
|
|
40
|
+
&& item.refs.every((ref) => typeof ref === "string")));
|
|
41
|
+
}
|
|
42
|
+
function validActiveSessionRecord(path, change, repoRoot, event) {
|
|
43
|
+
const record = readJson(path);
|
|
44
|
+
if (!record)
|
|
45
|
+
return false;
|
|
46
|
+
if (record.schema_version !== 2 || record.kind !== "hook_active_session")
|
|
47
|
+
return false;
|
|
48
|
+
if (record.trust !== "audit-only" && record.trust !== "trusted")
|
|
49
|
+
return false;
|
|
50
|
+
if (record.strict_profile !== "available" && record.strict_profile !== "unavailable")
|
|
51
|
+
return false;
|
|
52
|
+
if (record.change_id !== change)
|
|
53
|
+
return false;
|
|
54
|
+
if (resolve(String(record.repo_root ?? "")) !== resolve(repoRoot))
|
|
55
|
+
return false;
|
|
56
|
+
if (typeof record.session_id !== "string" || !record.session_id)
|
|
57
|
+
return false;
|
|
58
|
+
if (!validReasonArray(record.audit_only_reasons))
|
|
59
|
+
return false;
|
|
60
|
+
const eventSessionId = typeof event.session_id === "string" && event.session_id ? event.session_id : "";
|
|
61
|
+
const auditRepoChangeLeaseFallback = record.trust === "audit-only"
|
|
62
|
+
&& record.strict_profile === "unavailable"
|
|
63
|
+
&& record.session_id === "manual-cli-session";
|
|
64
|
+
if (eventSessionId && record.session_id !== eventSessionId && !auditRepoChangeLeaseFallback)
|
|
65
|
+
return false;
|
|
66
|
+
const expiresAt = Date.parse(String(record.expires_at ?? ""));
|
|
67
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now())
|
|
68
|
+
return false;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
function inferChangeFromActiveSessions(event) {
|
|
72
|
+
const repoRoot = findRepoRootFromCwd(event);
|
|
73
|
+
const changesDir = join(repoRoot, "openspec", "changes");
|
|
74
|
+
if (!isDir(changesDir))
|
|
75
|
+
return { state: "none" };
|
|
76
|
+
const candidates = readdirSync(changesDir).filter((change) => {
|
|
77
|
+
const runtimeDir = join(changesDir, change, ".superspec", "hook-runtime");
|
|
78
|
+
const activeDir = join(changesDir, change, ".superspec", "hook-runtime", "active-sessions");
|
|
79
|
+
if (isDir(activeDir) && readdirSync(activeDir).some((name) => (name.endsWith(".json") && validActiveSessionRecord(join(activeDir, name), change, repoRoot, event))))
|
|
80
|
+
return true;
|
|
81
|
+
return isDir(runtimeDir);
|
|
82
|
+
});
|
|
83
|
+
const unique = [...new Set(candidates)];
|
|
84
|
+
if (unique.length === 1)
|
|
85
|
+
return { state: "unique", change: unique[0] };
|
|
86
|
+
if (unique.length > 1)
|
|
87
|
+
return { state: "ambiguous", changes: unique.sort() };
|
|
88
|
+
return { state: "none" };
|
|
89
|
+
}
|
|
90
|
+
function cwdRoot(event) {
|
|
91
|
+
return typeof event.cwd === "string" && event.cwd ? resolve(event.cwd) : process.cwd();
|
|
92
|
+
}
|
|
93
|
+
function findRepoRootFromCwd(event) {
|
|
94
|
+
let dir = cwdRoot(event);
|
|
95
|
+
while (true) {
|
|
96
|
+
if ((isDir(join(dir, "openspec")) || isDir(join(dir, ".codex"))) && statMaybe(join(dir, "package.json")) === "file")
|
|
97
|
+
return dir;
|
|
98
|
+
if (isDir(join(dir, "openspec", "changes")) || isDir(join(dir, ".codex")))
|
|
99
|
+
return dir;
|
|
100
|
+
if (statMaybe(join(dir, ".git")))
|
|
101
|
+
return dir;
|
|
102
|
+
const parent = dirname(dir);
|
|
103
|
+
if (parent === dir)
|
|
104
|
+
return cwdRoot(event);
|
|
105
|
+
dir = parent;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function passThrough(message) {
|
|
109
|
+
return {
|
|
110
|
+
hookSpecificOutput: {
|
|
111
|
+
hookEventName: "PreToolUse",
|
|
112
|
+
additionalContext: message,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function statMaybe(path) {
|
|
117
|
+
try {
|
|
118
|
+
const st = statSync(path);
|
|
119
|
+
if (st.isFile())
|
|
120
|
+
return "file";
|
|
121
|
+
if (st.isDirectory())
|
|
122
|
+
return "dir";
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function denyPreToolUse(reason) {
|
|
130
|
+
return {
|
|
131
|
+
hookSpecificOutput: {
|
|
132
|
+
hookEventName: "PreToolUse",
|
|
133
|
+
permissionDecision: "deny",
|
|
134
|
+
permissionDecisionReason: reason,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function isTestValidationCommand(event) {
|
|
139
|
+
if (String(event.tool_name ?? "") !== "Bash")
|
|
140
|
+
return false;
|
|
141
|
+
const toolInput = isObject(event.tool_input) ? event.tool_input : {};
|
|
142
|
+
const command = typeof toolInput.command === "string" ? toolInput.command : "";
|
|
143
|
+
return commandLooksLikeTestValidation(command);
|
|
144
|
+
}
|
|
145
|
+
function inertPostToolUseOutput() {
|
|
146
|
+
return {
|
|
147
|
+
systemMessage: "SuperSpec hook ignored non-test command; no test telemetry was recorded.",
|
|
148
|
+
hookSpecificOutput: {
|
|
149
|
+
hookEventName: "PostToolUse",
|
|
150
|
+
additionalContext: "SuperSpec runtime telemetry is inert for non-test commands.",
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function noChangeFallback(event, inference) {
|
|
155
|
+
if (String(event.hook_event_name ?? "PreToolUse") !== "PreToolUse") {
|
|
156
|
+
return { systemMessage: "SuperSpec hook inert: SUPERSPEC_CHANGE/--change not set" };
|
|
157
|
+
}
|
|
158
|
+
const intent = extractWriteIntent(event, findRepoRootFromCwd(event));
|
|
159
|
+
const writeCapable = intent.target_paths.length > 0
|
|
160
|
+
|| intent.archive_command
|
|
161
|
+
|| intent.internal_hook_writer_command
|
|
162
|
+
|| intent.unsupported_write_surface
|
|
163
|
+
|| intent.shell_write_command
|
|
164
|
+
|| intent.reasons.length > 0
|
|
165
|
+
|| ["apply_patch", "Edit", "Write"].includes(String(event.tool_name ?? ""));
|
|
166
|
+
if (inference.state === "ambiguous" && writeCapable) {
|
|
167
|
+
return denyPreToolUse(`blocked by SuperSpec hook policy: multiple active SuperSpec sessions (${inference.changes.join(", ")}); set SUPERSPEC_CHANGE or close stale leases`);
|
|
168
|
+
}
|
|
169
|
+
if (intent.internal_hook_writer_command) {
|
|
170
|
+
return denyPreToolUse("blocked by SuperSpec hook policy: internal hook writer invocation requires Guard-owned hook authority");
|
|
171
|
+
}
|
|
172
|
+
if (intent.unsafe_lifecycle_termination_command) {
|
|
173
|
+
return denyPreToolUse("blocked by SuperSpec hook policy: hook session cancellation requires trusted terminal authority");
|
|
174
|
+
}
|
|
175
|
+
if (intent.reasons.some((item) => item.code === "target_path_outside_repo")) {
|
|
176
|
+
return denyPreToolUse("blocked by SuperSpec hook policy: write target escapes the current repository root");
|
|
177
|
+
}
|
|
178
|
+
if (intent.reasons.some((item) => item.code === "protected_trust_root_link_source")) {
|
|
179
|
+
return denyPreToolUse("blocked by SuperSpec hook policy: link source points at a SuperSpec trust root");
|
|
180
|
+
}
|
|
181
|
+
if (intent.reasons.some((item) => item.code === "shell_path_may_touch_trust_root")) {
|
|
182
|
+
return denyPreToolUse("blocked by SuperSpec hook policy: shell-expanded path may touch SuperSpec trust roots");
|
|
183
|
+
}
|
|
184
|
+
if (intent.reasons.some((item) => item.code === "curl_config_write_target_unknown")) {
|
|
185
|
+
return denyPreToolUse("blocked by SuperSpec hook policy: curl config-driven writes have unknown targets");
|
|
186
|
+
}
|
|
187
|
+
if (intent.reasons.some((item) => item.code === "curl_write_target_unknown")) {
|
|
188
|
+
return denyPreToolUse("blocked by SuperSpec hook policy: curl write options have unknown targets");
|
|
189
|
+
}
|
|
190
|
+
if (intent.target_paths.some((path) => isSuperSpecTrustRootPath(path))) {
|
|
191
|
+
return denyPreToolUse("blocked by SuperSpec hook policy: direct writes to SuperSpec trust roots require an active Guard context");
|
|
192
|
+
}
|
|
193
|
+
if (intent.shell_write_command && intent.trust_root_text_matches.length > 0) {
|
|
194
|
+
return denyPreToolUse("blocked by SuperSpec hook policy: pathless write-capable command appears to touch SuperSpec trust roots");
|
|
195
|
+
}
|
|
196
|
+
if (intent.archive_command) {
|
|
197
|
+
return denyPreToolUse("blocked by SuperSpec hook policy: openspec archive requires an active Guard context and archive_ready allow");
|
|
198
|
+
}
|
|
199
|
+
return passThrough("SuperSpec hook inert: SUPERSPEC_CHANGE/--change not set");
|
|
200
|
+
}
|
|
201
|
+
function preToolUseOutput(decision) {
|
|
202
|
+
if (!decision.allowed) {
|
|
203
|
+
return {
|
|
204
|
+
hookSpecificOutput: {
|
|
205
|
+
hookEventName: "PreToolUse",
|
|
206
|
+
permissionDecision: "deny",
|
|
207
|
+
permissionDecisionReason: decision.block_reasons.map((item) => item.message).join("; ") || "blocked by SuperSpec hook policy",
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
hookSpecificOutput: {
|
|
213
|
+
hookEventName: "PreToolUse",
|
|
214
|
+
additionalContext: `SuperSpec hook ${decision.enforcement}; strict_profile=${decision.strict_profile}; trust=${decision.trust}`,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function postToolUseOutput(decision) {
|
|
219
|
+
return {
|
|
220
|
+
systemMessage: `SuperSpec hook telemetry recorded as ${decision.trust}; strict_profile=${decision.strict_profile}`,
|
|
221
|
+
hookSpecificOutput: {
|
|
222
|
+
hookEventName: "PostToolUse",
|
|
223
|
+
additionalContext: `SuperSpec runtime evidence is ${decision.strict_profile === "available" ? "runtime-verified" : "audit-only"}.`,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function subagentOutput(decision, eventName) {
|
|
228
|
+
return {
|
|
229
|
+
systemMessage: `SuperSpec ${eventName} runlog recorded as ${decision.trust}; strict_profile=${decision.strict_profile}`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function writeTempEvent(event) {
|
|
233
|
+
const path = join(tmpdir(), `superspec-hook-event-${randomUUID()}.json`);
|
|
234
|
+
writeFileSync(path, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
235
|
+
return path;
|
|
236
|
+
}
|
|
237
|
+
function validateHookEvent(event) {
|
|
238
|
+
const eventName = typeof event.hook_event_name === "string" ? event.hook_event_name : "";
|
|
239
|
+
if (!eventName)
|
|
240
|
+
return "hook event missing required hook_event_name";
|
|
241
|
+
if (!["PreToolUse", "PostToolUse", "SubagentStart", "SubagentStop"].includes(eventName)) {
|
|
242
|
+
return `unsupported hook_event_name: ${eventName}`;
|
|
243
|
+
}
|
|
244
|
+
if (typeof event.cwd !== "string" || !event.cwd)
|
|
245
|
+
return "hook event missing required cwd";
|
|
246
|
+
if (eventName === "PreToolUse") {
|
|
247
|
+
if (typeof event.tool_name !== "string" || !event.tool_name)
|
|
248
|
+
return "PreToolUse hook event missing required tool_name";
|
|
249
|
+
if (!isObject(event.tool_input))
|
|
250
|
+
return "PreToolUse hook event missing required tool_input object";
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
export function runHookAdapter(argv = process.argv.slice(2), stdin = readFileSync(0, "utf8")) {
|
|
255
|
+
const parsedArgs = parseArgs(argv);
|
|
256
|
+
let event;
|
|
257
|
+
try {
|
|
258
|
+
if (!stdin.trim())
|
|
259
|
+
throw new Error("hook stdin is empty");
|
|
260
|
+
const parsed = JSON.parse(stdin);
|
|
261
|
+
if (!isObject(parsed))
|
|
262
|
+
throw new Error("hook stdin must be a JSON object");
|
|
263
|
+
event = parsed;
|
|
264
|
+
const validationError = validateHookEvent(event);
|
|
265
|
+
if (validationError)
|
|
266
|
+
throw new Error(validationError);
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
return { code: 2, stdout: "", stderr: `SuperSpec hook event parse failed: ${err.message}\n` };
|
|
270
|
+
}
|
|
271
|
+
const inference = parsedArgs.change ? { state: "unique", change: parsedArgs.change } : inferChangeFromActiveSessions(event);
|
|
272
|
+
const change = inference.state === "unique" ? inference.change : null;
|
|
273
|
+
if (!change) {
|
|
274
|
+
const payload = noChangeFallback(event, inference);
|
|
275
|
+
return { code: 0, stdout: `${JSON.stringify(payload)}\n`, stderr: "" };
|
|
276
|
+
}
|
|
277
|
+
const eventRef = writeTempEvent(event);
|
|
278
|
+
try {
|
|
279
|
+
const eventName = String(event.hook_event_name ?? "");
|
|
280
|
+
let payload;
|
|
281
|
+
if (eventName === "PreToolUse") {
|
|
282
|
+
const toolName = String(event.tool_name ?? "");
|
|
283
|
+
const decision = toolName === "Bash" ? hookCheckCommand(change, eventRef) : hookCheckWrite(change, eventRef);
|
|
284
|
+
payload = preToolUseOutput(decision);
|
|
285
|
+
}
|
|
286
|
+
else if (eventName === "PostToolUse") {
|
|
287
|
+
payload = isTestValidationCommand(event) ? postToolUseOutput(hookRecordTest(change, eventRef)) : inertPostToolUseOutput();
|
|
288
|
+
}
|
|
289
|
+
else if (eventName === "SubagentStart") {
|
|
290
|
+
payload = subagentOutput(hookRecordSubagentStart(change, eventRef), "SubagentStart");
|
|
291
|
+
}
|
|
292
|
+
else if (eventName === "SubagentStop") {
|
|
293
|
+
payload = subagentOutput(hookRecordSubagentStop(change, eventRef), "SubagentStop");
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
payload = { systemMessage: `SuperSpec hook ignored unsupported event ${eventName}` };
|
|
297
|
+
}
|
|
298
|
+
return { code: 0, stdout: `${JSON.stringify(payload)}\n`, stderr: "" };
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
return { code: 2, stdout: "", stderr: `SuperSpec hook failed closed: ${err.message}\n` };
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
try {
|
|
305
|
+
unlinkSync(resolve(eventRef));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// best effort temp cleanup
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Reason } from "../util.ts";
|
|
2
|
+
import type { HookDecision } from "./types.ts";
|
|
3
|
+
export declare function hookCheckWrite(change: string, eventRef: string): HookDecision;
|
|
4
|
+
export declare function hookCheckCommand(change: string, eventRef: string): HookDecision;
|
|
5
|
+
export declare function hookHealth(change: string): HookDecision;
|
|
6
|
+
export declare function hookSessionBegin(change: string, workflow: string, _entrypointToken: string): HookDecision;
|
|
7
|
+
export declare function hookSessionStatus(change: string): HookDecision;
|
|
8
|
+
export declare function hookSessionEnd(change: string, endReason: string, _lifecycleToken: string): HookDecision;
|
|
9
|
+
export declare function hookRecordTest(change: string, eventRef: string): HookDecision;
|
|
10
|
+
export declare function hookRecordSubagentStart(change: string, eventRef: string): HookDecision;
|
|
11
|
+
export declare function hookRecordSubagentStop(change: string, eventRef: string): HookDecision;
|
|
12
|
+
export declare function hookInitReasons(repoRoot: string): Reason[];
|