@structor-dev/cli 0.1.0 → 0.2.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +131 -21
  3. package/ROADMAP.md +38 -0
  4. package/SECURITY.md +33 -0
  5. package/bin/structor.mjs +553 -29
  6. package/contrib/self-harness/files/README.md +32 -0
  7. package/contrib/self-harness/files/ai/AGENTS.md +35 -0
  8. package/contrib/self-harness/files/ai/ARCHITECTURE.md +38 -0
  9. package/contrib/self-harness/files/ai/HUB.md +59 -0
  10. package/contrib/self-harness/files/ai/PRODUCT.md +36 -0
  11. package/contrib/self-harness/files/ai/QUALITY.md +31 -0
  12. package/contrib/self-harness/files/ai/context.md +38 -0
  13. package/contrib/self-harness/files/scripts/check-workspace.mjs +72 -0
  14. package/contrib/self-harness/harness.config.json +37 -0
  15. package/docs/CONTRIBUTOR-SETUP.md +45 -0
  16. package/docs/INIT.md +55 -2
  17. package/docs/public-launch.md +150 -0
  18. package/examples/anthropic-only/harness.config.json +26 -0
  19. package/examples/frontend-backend/harness.config.json +8 -8
  20. package/examples/generated-harness-tree.md +432 -0
  21. package/examples/openai-and-anthropic/harness.config.json +7 -7
  22. package/examples/single-repo/harness.config.json +7 -7
  23. package/harness.config.example.json +1 -1
  24. package/package.json +12 -4
  25. package/schemas/contract-manifest.schema.json +0 -1
  26. package/schemas/harness-config.schema.json +5 -2
  27. package/scripts/check-config.mjs +20 -31
  28. package/scripts/check-examples.mjs +146 -0
  29. package/scripts/check-public-hygiene.mjs +249 -0
  30. package/scripts/check-schemas.mjs +42 -0
  31. package/scripts/check-template-files.mjs +15 -98
  32. package/scripts/generated-harness-contract.mjs +416 -0
  33. package/scripts/init-harness.mjs +227 -139
  34. package/scripts/lib.mjs +462 -12
  35. package/scripts/rendered-config.mjs +109 -0
  36. package/scripts/setup-contributor.mjs +125 -0
  37. package/scripts/smoke-template.mjs +260 -73
  38. package/template/AGENTS.md.tpl +4 -2
  39. package/template/README.md.tpl +5 -0
  40. package/template/ai/CODEX-HOOKS.md.tpl +1 -1
  41. package/template/ai/HARNESS-ENGINEERING.md.tpl +5 -2
  42. package/template/ai/HARNESS.md.tpl +4 -1
  43. package/template/ai/contracts/codex-hooks.contract.json.tpl +58 -1
  44. package/template/ai/contracts/codex-hooks.md.tpl +6 -0
  45. package/template/ai/contracts/release-flow.md.tpl +1 -1
  46. package/template/ai/templates/fixtures/issues/valid-ready.md.tpl +3 -1
  47. package/template/ai/templates/issue-template.md.tpl +3 -1
  48. package/template/ai/workspace/LOCAL-STACK.md.tpl +1 -1
  49. package/template/ai/workspace/SYSTEM-MAP.md.tpl +2 -2
  50. package/template/consumer/AGENTS.md.tpl +4 -4
  51. package/template/consumer/CLAUDE.md.tpl +4 -4
  52. package/template/scripts/bootstrap-workspace.mjs.tpl +11 -25
  53. package/template/scripts/check-claude-compatibility.mjs.tpl +62 -9
  54. package/template/scripts/check-codex-hooks.mjs.tpl +262 -20
  55. package/template/scripts/check-template-governance.mjs.tpl +2 -114
  56. package/template/scripts/check-workspace.mjs.tpl +27 -103
  57. package/template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl +12 -0
  58. package/template/scripts/generate-html-views.mjs.tpl +357 -56
  59. package/template/scripts/generated-harness-contract.mjs.tpl +1 -0
  60. package/template/scripts/hooks/lib/codex-hooks-core.mjs.tpl +14 -3
  61. package/template/scripts/lib/path-safety.mjs.tpl +87 -0
  62. package/template/scripts/lib/worktree-bootstrap.mjs.tpl +16 -13
  63. package/template/scripts/validate-governance.mjs.tpl +52 -36
  64. package/schemas/task-brief.schema.json +0 -37
@@ -7,9 +7,15 @@ Codex hooks are local advisory guardrails.
7
7
  - Hook config lives in `.codex/hooks.json`.
8
8
  - Hook code lives under `scripts/hooks/`.
9
9
  - Hooks are deterministic and local.
10
+ - Hook config must contain only the expected generated events, entries, and
11
+ commands.
10
12
  - Deny rules must include remediation and policy references.
11
13
  - Hooks must not write files, call external services, or supervise long-running
12
14
  processes.
15
+ - Hook code must not import or call synchronous file mutation APIs,
16
+ write-capable streams, or write-capable file opens.
17
+ - Hook validation must scan hook scripts for banned mutation tokens before
18
+ importing or executing hook code.
13
19
  - Hooks are not a complete security boundary and do not replace sandboxing,
14
20
  permission controls, code review, CI policy, or secret management.
15
21
 
@@ -1,6 +1,6 @@
1
1
  # Release Flow Contract
2
2
 
3
- This contract defines harness release safety for `{{PROJECT_NAME}}`.
3
+ This contract defines harness release safety for {{PROJECT_NAME_CODE}}.
4
4
 
5
5
  ## Requirements
6
6
 
@@ -17,7 +17,9 @@ requires_human_approval: false
17
17
 
18
18
  ## Summary
19
19
 
20
- Update harness guidance without touching protected surfaces.
20
+ Update harness guidance without touching protected surfaces. The work stays in
21
+ `ai/HARNESS.md`, preserves harness-only scope, and is complete when governance
22
+ validation passes with `node scripts/validate-governance.mjs`.
21
23
 
22
24
  ## Context
23
25
 
@@ -17,7 +17,9 @@ requires_human_approval: false
17
17
 
18
18
  ## Summary
19
19
 
20
- Describe the work in one short paragraph.
20
+ Write a scannable implementation summary in 250 words or fewer. Include the
21
+ problem being solved, the intended change, the main implementation surfaces,
22
+ and the key validation expectation.
21
23
 
22
24
  ## Context
23
25
 
@@ -1,6 +1,6 @@
1
1
  # Local Stack
2
2
 
3
- This document records stable local assumptions for `{{PROJECT_NAME}}`.
3
+ This document records stable local assumptions for {{PROJECT_NAME_CODE}}.
4
4
 
5
5
  ## Harness
6
6
 
@@ -1,7 +1,7 @@
1
1
  # System Map
2
2
 
3
- `{{PROJECT_NAME}}` is organized around a separate harness repo and one or more
4
- consumer repos.
3
+ Project {{PROJECT_NAME_CODE}} is organized around a separate harness repo and
4
+ one or more consumer repos.
5
5
 
6
6
  ## Relationship
7
7
 
@@ -7,10 +7,10 @@ harness.
7
7
 
8
8
  Read the harness first:
9
9
 
10
- 1. `{{HARNESS_RELATIVE_PATH}}/AGENTS.md`
11
- 2. `{{HARNESS_RELATIVE_PATH}}/ai/AGENTS.md`
12
- 3. `{{HARNESS_RELATIVE_PATH}}/ai/HUB.md`
13
- 4. `{{HARNESS_RELATIVE_PATH}}/ai/context.md`
10
+ 1. Root guide: {{HARNESS_AGENTS_PATH}}
11
+ 2. Shared guide: {{HARNESS_AI_AGENTS_PATH}}
12
+ 3. Hub: {{HARNESS_AI_HUB_PATH}}
13
+ 4. Context: {{HARNESS_AI_CONTEXT_PATH}}
14
14
 
15
15
  ## Local Purpose
16
16
 
@@ -5,10 +5,10 @@ harness.
5
5
 
6
6
  Read:
7
7
 
8
- 1. `{{HARNESS_RELATIVE_PATH}}/CLAUDE.md`
9
- 2. `{{HARNESS_RELATIVE_PATH}}/ai/AGENTS.md`
10
- 3. `{{HARNESS_RELATIVE_PATH}}/ai/HUB.md`
11
- 4. `{{HARNESS_RELATIVE_PATH}}/ai/context.md`
8
+ 1. Root guide: {{HARNESS_CLAUDE_PATH}}
9
+ 2. Shared guide: {{HARNESS_AI_AGENTS_PATH}}
10
+ 3. Hub: {{HARNESS_AI_HUB_PATH}}
11
+ 4. Context: {{HARNESS_AI_CONTEXT_PATH}}
12
12
 
13
13
  Local purpose: {{CONSUMER_PURPOSE}}
14
14
 
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { cp, mkdir, access } from "node:fs/promises";
4
- import { constants as fsConstants } from "node:fs";
3
+ import { cp, mkdir } from "node:fs/promises";
5
4
  import path from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
6
  import { execFileSync } from "node:child_process";
7
+ import { workspaceEntrypointsForSettings } from "./generated-harness-contract.mjs";
8
+ import { assertSafeWriteTarget, exists } from "./lib/path-safety.mjs";
8
9
 
9
10
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
11
  const workspaceRoot = path.resolve(repoRoot, "..");
@@ -16,6 +17,7 @@ const models = {
16
17
  const clientSupport = {
17
18
  claudeRules: {{CLIENT_CLAUDE_RULES_ENABLED}},
18
19
  };
20
+ const workspaceEntrypoints = workspaceEntrypointsForSettings({ models, clientSupport });
19
21
 
20
22
  function parseArgs(argv) {
21
23
  return {
@@ -24,15 +26,6 @@ function parseArgs(argv) {
24
26
  };
25
27
  }
26
28
 
27
- async function exists(filePath) {
28
- try {
29
- await access(filePath, fsConstants.F_OK);
30
- return true;
31
- } catch {
32
- return false;
33
- }
34
- }
35
-
36
29
  async function copyIfAllowed(sourceRelative, targetRelative, options) {
37
30
  const source = path.join(repoRoot, sourceRelative);
38
31
  const target = path.join(workspaceRoot, targetRelative);
@@ -45,6 +38,11 @@ async function copyIfAllowed(sourceRelative, targetRelative, options) {
45
38
  console.log(`skipped existing ${target}`);
46
39
  return;
47
40
  }
41
+ await assertSafeWriteTarget({
42
+ targetPath: target,
43
+ rootPath: workspaceRoot,
44
+ label: `Workspace bootstrap target ${targetRelative}`,
45
+ });
48
46
  await mkdir(path.dirname(target), { recursive: true });
49
47
  await cp(source, target);
50
48
  console.log(`installed ${target}`);
@@ -67,20 +65,8 @@ async function main() {
67
65
  const options = parseArgs(process.argv.slice(2));
68
66
  await verifyConsumers();
69
67
 
70
- if (models.openai) {
71
- await copyIfAllowed("workspace/AGENTS.md", "AGENTS.md", options);
72
- }
73
- if (models.anthropic) {
74
- await copyIfAllowed("workspace/CLAUDE.md", "CLAUDE.md", options);
75
- await copyIfAllowed("workspace/.claude/CLAUDE.md", ".claude/CLAUDE.md", options);
76
- await copyIfAllowed("workspace/.claude/settings.json", ".claude/settings.json", options);
77
- if (clientSupport.claudeRules) {
78
- await copyIfAllowed(
79
- "workspace/.claude/rules/harness-client-surfaces.md",
80
- ".claude/rules/harness-client-surfaces.md",
81
- options,
82
- );
83
- }
68
+ for (const entrypoint of workspaceEntrypoints) {
69
+ await copyIfAllowed(entrypoint.source, entrypoint.path, options);
84
70
  }
85
71
 
86
72
  if (!options.dryRun) {
@@ -4,20 +4,21 @@ import { readFile, readdir, access } from "node:fs/promises";
4
4
  import { constants as fsConstants } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { requiredClaudeCompatibilityFiles } from "./generated-harness-contract.mjs";
7
8
 
8
9
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
10
  const claudeRulesEnabled = {{CLIENT_CLAUDE_RULES_ENABLED}};
10
11
  const claudeHooksEnabled = {{CLIENT_CLAUDE_HOOKS_ENABLED}};
11
12
  const claudeSkillsEnabled = {{CLIENT_CLAUDE_SKILLS_ENABLED}};
12
-
13
- const requiredFiles = [
14
- "CLAUDE.md",
15
- ".claude/CLAUDE.md",
16
- ".claude/settings.json",
17
- "ai/model-overlays/anthropic/CLAUDE.md",
18
- ];
19
-
20
- if (claudeRulesEnabled) requiredFiles.push(".claude/rules/harness-client-surfaces.md");
13
+ const settings = {
14
+ models: { openai: false, anthropic: true },
15
+ clientSupport: {
16
+ claudeRules: claudeRulesEnabled,
17
+ claudeHooks: claudeHooksEnabled,
18
+ claudeSkills: claudeSkillsEnabled,
19
+ },
20
+ };
21
+ const requiredFiles = requiredClaudeCompatibilityFiles(settings);
21
22
 
22
23
  async function exists(relativePath) {
23
24
  try {
@@ -56,6 +57,36 @@ function requireIncludes(content, needle, label, errors) {
56
57
  if (!content.includes(needle)) errors.push(`${label} must include '${needle}'.`);
57
58
  }
58
59
 
60
+ // Top-level keys the generated Claude settings file is allowed to declare.
61
+ const allowedSettingsKeys = new Set(["permissions", "env", "hooks", "model", "$schema"]);
62
+ const allowedPermissionsKeys = new Set(["allow", "deny", "ask", "defaultMode", "additionalDirectories"]);
63
+
64
+ // An allow entry is dangerous when it grants an unrestricted scope (a bare tool
65
+ // name, or a wildcard that matches everything). These must never appear in a
66
+ // generated harness because they neutralise the deny list.
67
+ function isDangerousAllowEntry(entry) {
68
+ if (typeof entry !== "string") return true;
69
+ const trimmed = entry.trim();
70
+ if (!/\(.*\)/.test(trimmed)) return true; // bare tool name, e.g. "Bash"
71
+ return /\((?:\*|\*\*(?:\/\*+)?)\)\s*$/.test(trimmed);
72
+ }
73
+
74
+ // Heuristic phrases that attempt to weaken or override harness safety policy.
75
+ const unsafePolicyPatterns = [
76
+ /\b(?:ignore|disregard|forget)\b[^.\n]*\b(?:previous|prior|above|all|the)\b[^.\n]*\b(?:instruction|rule|polic|guard|safety|deny)/i,
77
+ /\b(?:bypass|disable|override|relax|remove)\b[^.\n]*\b(?:safety|guard|deny|permission|restriction|policy)/i,
78
+ /\ballow\b[^.\n]*\b(?:any|all)\b[^.\n]*\b(?:command|tool|action)/i,
79
+ ];
80
+
81
+ function checkUnsafePolicyText(content, label, errors) {
82
+ for (const pattern of unsafePolicyPatterns) {
83
+ if (pattern.test(content)) {
84
+ errors.push(`${label} contains text that weakens harness safety policy.`);
85
+ return;
86
+ }
87
+ }
88
+ }
89
+
59
90
  const errors = [];
60
91
  for (const relativePath of requiredFiles) {
61
92
  if (!(await exists(relativePath))) errors.push(`${relativePath} is required for Claude Code compatibility.`);
@@ -69,19 +100,40 @@ if (errors.length === 0) {
69
100
  if (/^\s*1\.\s+`\.\/AGENTS\.md`/m.test(rootClaude)) {
70
101
  errors.push("CLAUDE.md must not require Claude Code to start from AGENTS.md.");
71
102
  }
103
+ checkUnsafePolicyText(rootClaude, "CLAUDE.md", errors);
72
104
 
73
105
  const claudeProject = await read(".claude/CLAUDE.md");
74
106
  requireIncludes(claudeProject, "root `CLAUDE.md`", ".claude/CLAUDE.md", errors);
107
+ checkUnsafePolicyText(claudeProject, ".claude/CLAUDE.md", errors);
75
108
 
76
109
  const settings = await readJson(".claude/settings.json");
110
+ for (const key of Object.keys(settings)) {
111
+ if (!allowedSettingsKeys.has(key)) {
112
+ errors.push(`.claude/settings.json declares unexpected top-level key '${key}'.`);
113
+ }
114
+ }
77
115
  if (!settings.permissions || !Array.isArray(settings.permissions.deny)) {
78
116
  errors.push(".claude/settings.json must define permissions.deny.");
79
117
  }
118
+ if (settings.permissions && typeof settings.permissions === "object") {
119
+ for (const key of Object.keys(settings.permissions)) {
120
+ if (!allowedPermissionsKeys.has(key)) {
121
+ errors.push(`.claude/settings.json permissions declares unexpected key '${key}'.`);
122
+ }
123
+ }
124
+ }
80
125
  for (const denyPattern of ["Read(./.agent.env)", "Read(./.env)", "Read(./.env.*)"]) {
81
126
  if (!settings.permissions?.deny?.includes(denyPattern)) {
82
127
  errors.push(`.claude/settings.json permissions.deny must include ${denyPattern}.`);
83
128
  }
84
129
  }
130
+ if (Array.isArray(settings.permissions?.allow)) {
131
+ for (const entry of settings.permissions.allow) {
132
+ if (isDangerousAllowEntry(entry)) {
133
+ errors.push(`.claude/settings.json permissions.allow grants an unsafe broad scope: ${JSON.stringify(entry)}.`);
134
+ }
135
+ }
136
+ }
85
137
  if (!claudeHooksEnabled && Object.hasOwn(settings, "hooks")) {
86
138
  errors.push(".claude/settings.json must not configure Claude hooks unless clientSupport.claude.hooks is enabled.");
87
139
  }
@@ -91,6 +143,7 @@ if (errors.length === 0) {
91
143
  for (const token of ["paths:", "AGENTS.md", "CLAUDE.md", ".claude/**", "ai/model-overlays/**"]) {
92
144
  requireIncludes(rule, token, ".claude/rules/harness-client-surfaces.md", errors);
93
145
  }
146
+ checkUnsafePolicyText(rule, ".claude/rules/harness-client-surfaces.md", errors);
94
147
  }
95
148
 
96
149
  if (claudeSkillsEnabled) {
@@ -4,7 +4,6 @@ import { readFile, readdir } from "node:fs/promises";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import { denyRules } from "./hooks/lib/codex-hooks-core.mjs";
8
7
 
9
8
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
9
 
@@ -26,6 +25,83 @@ const expectedEvents = [
26
25
  eventPostToolUse,
27
26
  eventStop,
28
27
  ];
28
+ const expectedEventSet = new Set(expectedEvents);
29
+ const syncFileMutationApis = [
30
+ "appendFileSync",
31
+ "chmodSync",
32
+ "chownSync",
33
+ "copyFileSync",
34
+ "cpSync",
35
+ "fchmodSync",
36
+ "fchownSync",
37
+ "fdatasyncSync",
38
+ "fsyncSync",
39
+ "ftruncateSync",
40
+ "futimesSync",
41
+ "lchmodSync",
42
+ "lchownSync",
43
+ "linkSync",
44
+ "lutimesSync",
45
+ "mkdirSync",
46
+ "mkdtempSync",
47
+ "renameSync",
48
+ "rmSync",
49
+ "rmdirSync",
50
+ "symlinkSync",
51
+ "truncateSync",
52
+ "unlinkSync",
53
+ "utimesSync",
54
+ "writeFileSync",
55
+ "writeSync",
56
+ "writevSync",
57
+ ];
58
+ const asyncFileMutationApis = [
59
+ "appendFile",
60
+ "chmod",
61
+ "chown",
62
+ "copyFile",
63
+ "cp",
64
+ "fchmod",
65
+ "fchown",
66
+ "fdatasync",
67
+ "fsync",
68
+ "ftruncate",
69
+ "futimes",
70
+ "lchmod",
71
+ "lchown",
72
+ "link",
73
+ "lutimes",
74
+ "mkdir",
75
+ "mkdtemp",
76
+ "rename",
77
+ "rm",
78
+ "rmdir",
79
+ "symlink",
80
+ "truncate",
81
+ "unlink",
82
+ "utimes",
83
+ "writeFile",
84
+ "writev",
85
+ ];
86
+ const writeOpenFlagPattern = String.raw`(?:a|a\+|as|as\+|ax|ax\+|r\+|rs\+|w|w\+|wx|wx\+)`;
87
+ const syncFileMutationPattern = new RegExp(`\\b(?:${syncFileMutationApis.join("|")})\\b`);
88
+ const asyncFileMutationPattern = new RegExp(`\\b(?:${asyncFileMutationApis.join("|")})\\s*\\(`);
89
+ const hookScriptBanRules = [
90
+ { pattern: /node:child_process|from\s+["']child_process["']/i, label: "process supervision" },
91
+ { pattern: /\b(fetch|XMLHttpRequest)\s*\(/, label: "network call" },
92
+ { pattern: /from\s+["']node:fs\/promises["']|from\s+["']fs\/promises["']/i, label: "file-system write-capable import" },
93
+ { pattern: asyncFileMutationPattern, label: "file mutation" },
94
+ { pattern: syncFileMutationPattern, label: "synchronous file mutation" },
95
+ { pattern: /\bcreateWriteStream\b/, label: "write-capable stream" },
96
+ {
97
+ pattern: new RegExp(`\\b(?:open|openSync)\\s*\\([^;\\n]*,\\s*["']${writeOpenFlagPattern}["']`, "i"),
98
+ label: "write-capable file open",
99
+ },
100
+ {
101
+ pattern: /\b(?:open|openSync)\s*\([^;\n]*\b(?:O_WRONLY|O_RDWR|O_CREAT|O_TRUNC|O_APPEND)\b/i,
102
+ label: "write-capable file open",
103
+ },
104
+ ];
29
105
 
30
106
  const fixtures = [
31
107
  {
@@ -49,6 +125,34 @@ const fixtures = [
49
125
  expectedAction: "deny",
50
126
  expectedExitCode: 2,
51
127
  },
128
+ {
129
+ name: "destructive-command-deny-global-option",
130
+ event: eventPreToolUse,
131
+ input: { toolInput: { cmd: "git -C /repo reset --hard HEAD" } },
132
+ expectedAction: "deny",
133
+ expectedExitCode: 2,
134
+ },
135
+ {
136
+ name: "force-push-deny-short-flag",
137
+ event: eventPreToolUse,
138
+ input: { toolInput: { cmd: "git push -f origin main" } },
139
+ expectedAction: "deny",
140
+ expectedExitCode: 2,
141
+ },
142
+ {
143
+ name: "force-push-deny-refspec",
144
+ event: eventPreToolUse,
145
+ input: { toolInput: { cmd: "git push origin +main" } },
146
+ expectedAction: "deny",
147
+ expectedExitCode: 2,
148
+ },
149
+ {
150
+ name: "secret-read-deny-printenv",
151
+ event: eventPreToolUse,
152
+ input: { toolInput: { cmd: "printenv SECRET_TOKEN" } },
153
+ expectedAction: "deny",
154
+ expectedExitCode: 2,
155
+ },
52
156
  {
53
157
  name: "failed-validation-context",
54
158
  event: eventPostToolUse,
@@ -72,6 +176,71 @@ const fixtures = [
72
176
  },
73
177
  ];
74
178
 
179
+ const invalidConfigFixtures = [
180
+ {
181
+ name: "extra-hook-command",
182
+ expectedMessage: "PreToolUse must configure exactly one hook command.",
183
+ mutate(config) {
184
+ config.hooks.PreToolUse[0].hooks.push({
185
+ type: "command",
186
+ command: "node scripts/hooks/extra-hook.mjs PreToolUse --json",
187
+ });
188
+ },
189
+ },
190
+ {
191
+ name: "extra-hook-entry",
192
+ expectedMessage: "Stop must configure exactly one hook entry.",
193
+ mutate(config) {
194
+ config.hooks.Stop.push({
195
+ matcher: "*",
196
+ timeoutMs: fixtureTimeoutMs,
197
+ hooks: [{ type: "command", command: "node scripts/hooks/codex-hook.mjs Stop --json" }],
198
+ });
199
+ },
200
+ },
201
+ {
202
+ name: "extra-hook-event",
203
+ expectedMessage: ".codex/hooks.json must not define unexpected CustomEvent hook entry.",
204
+ mutate(config) {
205
+ config.hooks.CustomEvent = [
206
+ {
207
+ matcher: "*",
208
+ timeoutMs: fixtureTimeoutMs,
209
+ hooks: [{ type: "command", command: "node scripts/hooks/codex-hook.mjs CustomEvent --json" }],
210
+ },
211
+ ];
212
+ },
213
+ },
214
+ ];
215
+
216
+ const invalidHookScriptFixtures = [
217
+ {
218
+ name: "sync-mutation-import",
219
+ source: "import { writeFileSync } from 'node:fs';\n",
220
+ expectedLabel: "synchronous file mutation",
221
+ },
222
+ ...syncFileMutationApis.map((api) => ({
223
+ name: `sync-mutation-${api}`,
224
+ source: `fs.${api}('hook-target');\n`,
225
+ expectedLabel: "synchronous file mutation",
226
+ })),
227
+ {
228
+ name: "write-stream-call",
229
+ source: "createWriteStream('hook.log');\n",
230
+ expectedLabel: "write-capable stream",
231
+ },
232
+ {
233
+ name: "write-open-call",
234
+ source: "openSync('hook.log', 'w');\n",
235
+ expectedLabel: "write-capable file open",
236
+ },
237
+ {
238
+ name: "write-open-constant",
239
+ source: "openSync('hook.log', constants.O_WRONLY | constants.O_CREAT);\n",
240
+ expectedLabel: "write-capable file open",
241
+ },
242
+ ];
243
+
75
244
  async function readJson(relativePath) {
76
245
  return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
77
246
  }
@@ -86,22 +255,86 @@ function hookCommandFor(event) {
86
255
  return hookCommandForEvent(event);
87
256
  }
88
257
 
89
- async function checkConfig(errors) {
90
- const config = await readJson(".codex/hooks.json");
258
+ function expectedHookConfig() {
259
+ return {
260
+ version: 1,
261
+ hooks: Object.fromEntries(
262
+ expectedEvents.map((event) => [
263
+ event,
264
+ [
265
+ {
266
+ matcher: "*",
267
+ timeoutMs: fixtureTimeoutMs,
268
+ hooks: [{ type: "command", command: hookCommandFor(event) }],
269
+ },
270
+ ],
271
+ ]),
272
+ ),
273
+ };
274
+ }
275
+
276
+ function cloneJson(value) {
277
+ return JSON.parse(JSON.stringify(value));
278
+ }
279
+
280
+ function checkConfigObject(errors, config, label = ".codex/hooks.json") {
91
281
  if (config.version !== 1) errors.push(".codex/hooks.json must set version: 1.");
282
+ if (!config.hooks || typeof config.hooks !== "object" || Array.isArray(config.hooks)) {
283
+ errors.push(`${label} must define hooks object.`);
284
+ return;
285
+ }
286
+ for (const event of Object.keys(config.hooks)) {
287
+ if (!expectedEventSet.has(event)) {
288
+ errors.push(`${label} must not define unexpected ${event} hook entry.`);
289
+ }
290
+ }
92
291
  for (const event of expectedEvents) {
93
292
  const entries = config.hooks?.[event];
94
293
  if (!Array.isArray(entries) || entries.length === 0) {
95
294
  errors.push(`.codex/hooks.json missing ${event} hook entry.`);
96
295
  continue;
97
296
  }
98
- const commands = entries.flatMap((entry) => (entry.hooks ?? []).map((hook) => hook.command));
297
+ for (const entry of entries) {
298
+ checkTimeoutLimit(errors, event, entry.timeoutMs);
299
+ }
300
+ if (entries.length !== 1) {
301
+ errors.push(`${event} must configure exactly one hook entry.`);
302
+ continue;
303
+ }
304
+ const [entry] = entries;
305
+ const hooks = entry.hooks;
306
+ if (!Array.isArray(hooks) || hooks.length !== 1) {
307
+ errors.push(`${event} must configure exactly one hook command.`);
308
+ continue;
309
+ }
310
+ const [hook] = hooks;
99
311
  const expectedCommand = hookCommandFor(event);
100
- if (!commands.includes(expectedCommand)) {
312
+ if (hook.type !== "command" || hook.command !== expectedCommand) {
101
313
  errors.push(`${event} must reference committed command '${expectedCommand}'.`);
102
314
  }
103
- for (const entry of entries) {
104
- checkTimeoutLimit(errors, event, entry.timeoutMs);
315
+ }
316
+ }
317
+
318
+ async function checkConfig(errors) {
319
+ checkConfigObject(errors, await readJson(".codex/hooks.json"));
320
+ }
321
+
322
+ function checkConfigFixtures(errors) {
323
+ for (const fixture of invalidConfigFixtures) {
324
+ const config = cloneJson(expectedHookConfig());
325
+ fixture.mutate(config);
326
+ const fixtureErrors = [];
327
+ checkConfigObject(fixtureErrors, config);
328
+ if (!fixtureErrors.some((error) => error.includes(fixture.expectedMessage))) {
329
+ errors.push(`${fixture.name}: missing expected config validation error '${fixture.expectedMessage}'.`);
330
+ }
331
+ }
332
+ }
333
+
334
+ function checkHookScriptSource(errors, relativePath, source) {
335
+ for (const banned of hookScriptBanRules) {
336
+ if (banned.pattern.test(source)) {
337
+ errors.push(`${relativePath} contains ${banned.label} token. Hook scripts must stay deterministic and local.`);
105
338
  }
106
339
  }
107
340
  }
@@ -111,23 +344,23 @@ async function checkHookScripts(errors) {
111
344
  const files = (await readdir(hooksDir, { recursive: true }))
112
345
  .filter((file) => file.endsWith(".mjs"))
113
346
  .map((file) => `scripts/hooks/${file}`);
114
- const bannedPatterns = [
115
- { pattern: /node:child_process|from\s+["']child_process["']/i, label: "process supervision" },
116
- { pattern: /\b(fetch|XMLHttpRequest)\s*\(/, label: "network call" },
117
- { pattern: /from\s+["']node:fs\/promises["']|from\s+["']fs\/promises["']/i, label: "file-system write-capable import" },
118
- { pattern: /\b(writeFile|appendFile|mkdir|rm|unlink|rmdir)\s*\(/, label: "file mutation" },
119
- ];
120
347
  for (const relativePath of files) {
121
348
  const source = await readFile(path.join(repoRoot, relativePath), "utf8");
122
- for (const banned of bannedPatterns) {
123
- if (banned.pattern.test(source)) {
124
- errors.push(`${relativePath} contains ${banned.label} token. Hook scripts must stay deterministic and local.`);
125
- }
349
+ checkHookScriptSource(errors, relativePath, source);
350
+ }
351
+ }
352
+
353
+ function checkHookScriptFixtures(errors) {
354
+ for (const fixture of invalidHookScriptFixtures) {
355
+ const fixtureErrors = [];
356
+ checkHookScriptSource(fixtureErrors, `fixture/${fixture.name}.mjs`, fixture.source);
357
+ if (!fixtureErrors.some((error) => error.includes(fixture.expectedLabel))) {
358
+ errors.push(`${fixture.name}: missing expected hook script validation error '${fixture.expectedLabel}'.`);
126
359
  }
127
360
  }
128
361
  }
129
362
 
130
- function checkDenyRules(errors) {
363
+ function checkDenyRules(errors, denyRules) {
131
364
  for (const rule of denyRules) {
132
365
  for (const field of ["id", "prevents", "remediation", "falsePositiveNote"]) {
133
366
  if (!rule[field]) errors.push(`deny rule missing ${field}.`);
@@ -176,9 +409,18 @@ function checkFixtures(errors) {
176
409
 
177
410
  const errors = [];
178
411
  await checkConfig(errors);
412
+ checkConfigFixtures(errors);
179
413
  await checkHookScripts(errors);
180
- checkDenyRules(errors);
181
- checkFixtures(errors);
414
+ checkHookScriptFixtures(errors);
415
+
416
+ if (errors.length === 0) {
417
+ const { denyRules } = await import("./hooks/lib/codex-hooks-core.mjs");
418
+ checkDenyRules(errors, denyRules);
419
+ }
420
+
421
+ if (errors.length === 0) {
422
+ checkFixtures(errors);
423
+ }
182
424
 
183
425
  if (errors.length > 0) {
184
426
  console.error("Codex hook check failed.");