@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.
- package/CHANGELOG.md +56 -0
- package/README.md +131 -21
- package/ROADMAP.md +38 -0
- package/SECURITY.md +33 -0
- package/bin/structor.mjs +553 -29
- package/contrib/self-harness/files/README.md +32 -0
- package/contrib/self-harness/files/ai/AGENTS.md +35 -0
- package/contrib/self-harness/files/ai/ARCHITECTURE.md +38 -0
- package/contrib/self-harness/files/ai/HUB.md +59 -0
- package/contrib/self-harness/files/ai/PRODUCT.md +36 -0
- package/contrib/self-harness/files/ai/QUALITY.md +31 -0
- package/contrib/self-harness/files/ai/context.md +38 -0
- package/contrib/self-harness/files/scripts/check-workspace.mjs +72 -0
- package/contrib/self-harness/harness.config.json +37 -0
- package/docs/CONTRIBUTOR-SETUP.md +45 -0
- package/docs/INIT.md +55 -2
- package/docs/public-launch.md +150 -0
- package/examples/anthropic-only/harness.config.json +26 -0
- package/examples/frontend-backend/harness.config.json +8 -8
- package/examples/generated-harness-tree.md +432 -0
- package/examples/openai-and-anthropic/harness.config.json +7 -7
- package/examples/single-repo/harness.config.json +7 -7
- package/harness.config.example.json +1 -1
- package/package.json +12 -4
- package/schemas/contract-manifest.schema.json +0 -1
- package/schemas/harness-config.schema.json +5 -2
- package/scripts/check-config.mjs +20 -31
- package/scripts/check-examples.mjs +146 -0
- package/scripts/check-public-hygiene.mjs +249 -0
- package/scripts/check-schemas.mjs +42 -0
- package/scripts/check-template-files.mjs +15 -98
- package/scripts/generated-harness-contract.mjs +416 -0
- package/scripts/init-harness.mjs +227 -139
- package/scripts/lib.mjs +462 -12
- package/scripts/rendered-config.mjs +109 -0
- package/scripts/setup-contributor.mjs +125 -0
- package/scripts/smoke-template.mjs +260 -73
- package/template/AGENTS.md.tpl +4 -2
- package/template/README.md.tpl +5 -0
- package/template/ai/CODEX-HOOKS.md.tpl +1 -1
- package/template/ai/HARNESS-ENGINEERING.md.tpl +5 -2
- package/template/ai/HARNESS.md.tpl +4 -1
- package/template/ai/contracts/codex-hooks.contract.json.tpl +58 -1
- package/template/ai/contracts/codex-hooks.md.tpl +6 -0
- package/template/ai/contracts/release-flow.md.tpl +1 -1
- package/template/ai/templates/fixtures/issues/valid-ready.md.tpl +3 -1
- package/template/ai/templates/issue-template.md.tpl +3 -1
- package/template/ai/workspace/LOCAL-STACK.md.tpl +1 -1
- package/template/ai/workspace/SYSTEM-MAP.md.tpl +2 -2
- package/template/consumer/AGENTS.md.tpl +4 -4
- package/template/consumer/CLAUDE.md.tpl +4 -4
- package/template/scripts/bootstrap-workspace.mjs.tpl +11 -25
- package/template/scripts/check-claude-compatibility.mjs.tpl +62 -9
- package/template/scripts/check-codex-hooks.mjs.tpl +262 -20
- package/template/scripts/check-template-governance.mjs.tpl +2 -114
- package/template/scripts/check-workspace.mjs.tpl +27 -103
- package/template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl +12 -0
- package/template/scripts/generate-html-views.mjs.tpl +357 -56
- package/template/scripts/generated-harness-contract.mjs.tpl +1 -0
- package/template/scripts/hooks/lib/codex-hooks-core.mjs.tpl +14 -3
- package/template/scripts/lib/path-safety.mjs.tpl +87 -0
- package/template/scripts/lib/worktree-bootstrap.mjs.tpl +16 -13
- package/template/scripts/validate-governance.mjs.tpl +52 -36
- 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
|
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -7,10 +7,10 @@ harness.
|
|
|
7
7
|
|
|
8
8
|
Read the harness first:
|
|
9
9
|
|
|
10
|
-
1.
|
|
11
|
-
2.
|
|
12
|
-
3.
|
|
13
|
-
4.
|
|
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.
|
|
9
|
-
2.
|
|
10
|
-
3.
|
|
11
|
-
4.
|
|
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
|
|
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
|
-
|
|
71
|
-
await copyIfAllowed(
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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 (
|
|
312
|
+
if (hook.type !== "command" || hook.command !== expectedCommand) {
|
|
101
313
|
errors.push(`${event} must reference committed command '${expectedCommand}'.`);
|
|
102
314
|
}
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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.");
|