Skip to content

Commit d582b3c

Browse files
committed
Require trust before repo automation runs
1 parent 1fc9bcc commit d582b3c

6 files changed

Lines changed: 181 additions & 2 deletions

File tree

‎.codex/hooks.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"hooks": [
77
{
88
"type": "command",
9-
"command": "[ ! -f \".agents/skills/impeccable/scripts/hook.mjs\" ] || node \".agents/skills/impeccable/scripts/hook.mjs\"",
9+
"command": "trusted_revision=\"${DIFFSPLAIN_TRUSTED_AUTOMATION_REV:-}\"; current_revision=\"$(git rev-parse HEAD 2>/dev/null || true)\"; automation_changes=\"$(git status --porcelain -- .codex .agents AGENTS.md skills-lock.json)\"; if [ -n \"$current_revision\" ] && [ \"$trusted_revision\" = \"$current_revision\" ] && [ -z \"$automation_changes\" ]; then node \".agents/skills/impeccable/scripts/hook.mjs\"; else printf '%s\\n' \"Diffsplain trust warning: skipped repo-owned hook automation. Review .codex/, .agents/, AGENTS.md, and skills-lock.json. To accept this exact clean checkout, set DIFFSPLAIN_TRUSTED_AUTOMATION_REV to $(git rev-parse HEAD).\" >&2; fi",
1010
"timeout": 5,
1111
"statusMessage": "Checking UI changes"
1212
}
@@ -18,7 +18,7 @@
1818
"hooks": [
1919
{
2020
"type": "command",
21-
"command": "[ ! -f \".agents/skills/impeccable/scripts/hook.mjs\" ] || node \".agents/skills/impeccable/scripts/hook.mjs\"",
21+
"command": "trusted_revision=\"${DIFFSPLAIN_TRUSTED_AUTOMATION_REV:-}\"; current_revision=\"$(git rev-parse HEAD 2>/dev/null || true)\"; automation_changes=\"$(git status --porcelain -- .codex .agents AGENTS.md skills-lock.json)\"; if [ -n \"$current_revision\" ] && [ \"$trusted_revision\" = \"$current_revision\" ] && [ -z \"$automation_changes\" ]; then node \".agents/skills/impeccable/scripts/hook.mjs\"; else printf '%s\\n' \"Diffsplain trust warning: skipped repo-owned hook automation. Review .codex/, .agents/, AGENTS.md, and skills-lock.json. To accept this exact clean checkout, set DIFFSPLAIN_TRUSTED_AUTOMATION_REV to $(git rev-parse HEAD).\" >&2; fi",
2222
"timeout": 30,
2323
"statusMessage": "Design deep pass"
2424
}

‎.github/CODEOWNERS‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Repo-owned agent automation needs explicit review before merge.
2+
/.codex/ @itsjling
3+
/.agents/ @itsjling
4+
/AGENTS.md @itsjling
5+
/skills-lock.json @itsjling

‎AGENTS.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
- Keep the root README limited to command use and local development.
66
- Keep the landing page in `site/` and product docs in `docs/`.
77

8+
## Automation trust
9+
10+
Treat `.codex/`, `.agents/`, `AGENTS.md`, and `skills-lock.json` as untrusted
11+
in a fresh checkout or after changing revisions. Review their diff before
12+
running any repo-owned automation. The Codex hook only runs when the checkout
13+
is clean and `DIFFSPLAIN_TRUSTED_AUTOMATION_REV` equals its full `HEAD` commit.
14+
Set that variable only after this review. A change to one of these paths clears
15+
the trust decision and prints a warning instead of running the hook.
16+
817
## Agent skills
918

1019
### Issue tracker

‎docs/content/development.mdx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ npm test
2626

2727
`npm test` builds the app and runs the Node test suite.
2828

29+
## Trust repo automation
30+
31+
The checked-in Codex hook runs vendored automation. A fresh checkout does not
32+
run it. Review `.codex/`, `.agents/`, `AGENTS.md`, and `skills-lock.json` at
33+
the commit you plan to use. Then, for that clean checkout only, set:
34+
35+
```sh
36+
export DIFFSPLAIN_TRUSTED_AUTOMATION_REV="$(git rev-parse HEAD)"
37+
```
38+
39+
The hook runs only while the variable matches `HEAD` and those paths have no
40+
staged, unstaged, or untracked changes. Any change prints a trust warning and
41+
skips the hook. Clear or update the variable after you review another revision.
42+
43+
`skills-lock.json` records the source, version, path, and content hash for
44+
vendored skills. Pull requests that change a hook manifest, vendored automation,
45+
agent instructions, or this lock file request the automation owner's review.
46+
2947
## Publish a release
3048

3149
Commit all release changes, then pass a version and any extra `npm version`

‎skills-lock.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
{
22
"version": 1,
33
"skills": {
4+
"impeccable": {
5+
"source": "pbakaus/impeccable",
6+
"sourceType": "github",
7+
"version": "4.0.2",
8+
"skillPath": "skill/SKILL.md",
9+
"computedHash": "d61672b057c247542e8a4884d68794e5b0cc1198446d23aae0321180c23f1521"
10+
},
411
"setup-matt-pocock-skills": {
512
"source": "mattpocock/skills",
613
"sourceType": "github",

‎tests/automation-trust.test.mjs‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import assert from 'node:assert/strict';
2+
import { execFileSync, spawnSync } from 'node:child_process';
3+
import {
4+
mkdir,
5+
mkdtemp,
6+
readFile,
7+
rm,
8+
writeFile,
9+
} from 'node:fs/promises';
10+
import { tmpdir } from 'node:os';
11+
import { dirname, join } from 'node:path';
12+
import test from 'node:test';
13+
14+
const projectRoot = new URL('..', import.meta.url).pathname;
15+
const hooksPath = join(projectRoot, '.codex', 'hooks.json');
16+
const codeownersPath = join(projectRoot, '.github', 'CODEOWNERS');
17+
const protectedPaths = [
18+
'.codex/hooks.json',
19+
'.agents/skills/example-hook.mjs',
20+
'AGENTS.md',
21+
'skills-lock.json',
22+
];
23+
24+
function git(repo, ...args) {
25+
return execFileSync('git', ['-C', repo, ...args], {
26+
encoding: 'utf8',
27+
stdio: ['ignore', 'pipe', 'pipe'],
28+
}).trim();
29+
}
30+
31+
const hookManifest = JSON.parse(await readFile(hooksPath, 'utf8'));
32+
33+
function hookCommand() {
34+
const manifest = hookManifest;
35+
return manifest.hooks.PostToolUse[0].hooks[0].command;
36+
}
37+
38+
async function makeCheckout() {
39+
const repo = await mkdtemp(join(tmpdir(), 'diffsplain-automation-trust-'));
40+
await mkdir(join(repo, '.codex'), { recursive: true });
41+
await mkdir(join(repo, '.agents', 'skills', 'impeccable', 'scripts'), {
42+
recursive: true,
43+
});
44+
await writeFile(join(repo, '.codex', 'hooks.json'), '{}\n');
45+
await writeFile(join(repo, 'AGENTS.md'), 'Instructions\n');
46+
await writeFile(join(repo, 'skills-lock.json'), '{}\n');
47+
await writeFile(
48+
join(repo, '.agents', 'skills', 'impeccable', 'scripts', 'hook.mjs'),
49+
"import { writeFileSync } from 'node:fs';\nwriteFileSync('.hook-ran', 'yes\\n');\n",
50+
);
51+
git(repo, 'init', '-q');
52+
git(repo, 'config', 'user.email', 'diffsplain@example.test');
53+
git(repo, 'config', 'user.name', 'Diffsplain');
54+
git(repo, 'add', '.');
55+
git(repo, 'commit', '-qm', 'trusted automation');
56+
return repo;
57+
}
58+
59+
function runHook(repo, env = {}) {
60+
return spawnSync('sh', ['-c', hookCommand()], {
61+
cwd: repo,
62+
encoding: 'utf8',
63+
env: { ...process.env, ...env },
64+
});
65+
}
66+
67+
test('skips repo-owned hook code until this checkout revision is trusted', async () => {
68+
const repo = await makeCheckout();
69+
70+
try {
71+
const untrusted = runHook(repo);
72+
assert.equal(untrusted.status, 0, untrusted.stderr);
73+
await assert.rejects(readFile(join(repo, '.hook-ran'), 'utf8'));
74+
assert.match(untrusted.stderr, /trust warning: skipped repo-owned hook automation/i);
75+
76+
const trusted = runHook(repo, {
77+
DIFFSPLAIN_TRUSTED_AUTOMATION_REV: git(repo, 'rev-parse', 'HEAD'),
78+
});
79+
assert.equal(trusted.status, 0, trusted.stderr);
80+
assert.equal(await readFile(join(repo, '.hook-ran'), 'utf8'), 'yes\n');
81+
} finally {
82+
await rm(repo, { recursive: true, force: true });
83+
}
84+
});
85+
86+
test('changes to automation files revoke trust and request review', async () => {
87+
for (const file of protectedPaths) {
88+
const repo = await makeCheckout();
89+
90+
try {
91+
await mkdir(dirname(join(repo, file)), { recursive: true });
92+
await writeFile(join(repo, file), 'changed\n');
93+
const result = runHook(repo, {
94+
DIFFSPLAIN_TRUSTED_AUTOMATION_REV: git(repo, 'rev-parse', 'HEAD'),
95+
});
96+
97+
assert.equal(result.status, 0, `${file}: ${result.stderr}`);
98+
await assert.rejects(readFile(join(repo, '.hook-ran'), 'utf8'));
99+
assert.match(result.stderr, /trust warning: skipped repo-owned hook automation/i);
100+
} finally {
101+
await rm(repo, { recursive: true, force: true });
102+
}
103+
}
104+
});
105+
106+
test('staged automation changes also revoke trust', async () => {
107+
const repo = await makeCheckout();
108+
109+
try {
110+
await writeFile(join(repo, 'AGENTS.md'), 'changed\n');
111+
git(repo, 'add', 'AGENTS.md');
112+
const result = runHook(repo, {
113+
DIFFSPLAIN_TRUSTED_AUTOMATION_REV: git(repo, 'rev-parse', 'HEAD'),
114+
});
115+
116+
assert.equal(result.status, 0, result.stderr);
117+
await assert.rejects(readFile(join(repo, '.hook-ran'), 'utf8'));
118+
assert.match(result.stderr, /trust warning: skipped repo-owned hook automation/i);
119+
} finally {
120+
await rm(repo, { recursive: true, force: true });
121+
}
122+
});
123+
124+
test('records vendored hook provenance and protects every trust boundary', async () => {
125+
const lock = JSON.parse(
126+
await readFile(join(projectRoot, 'skills-lock.json'), 'utf8'),
127+
);
128+
const codeowners = await readFile(codeownersPath, 'utf8');
129+
130+
assert.deepEqual(lock.skills.impeccable, {
131+
source: 'pbakaus/impeccable',
132+
sourceType: 'github',
133+
version: '4.0.2',
134+
skillPath: 'skill/SKILL.md',
135+
computedHash: 'd61672b057c247542e8a4884d68794e5b0cc1198446d23aae0321180c23f1521',
136+
});
137+
for (const path of ['/.codex/', '/.agents/', '/AGENTS.md', '/skills-lock.json']) {
138+
assert.match(codeowners, new RegExp(`^${path.replaceAll('.', '\\.')} @itsjling$`, 'm'));
139+
}
140+
});

0 commit comments

Comments
 (0)