Skip to content

Commit 5b94726

Browse files
committed
feat: add anti --no-verify safeguard hooks - W-23834877
Ports the --no-verify-blocking safeguard pattern from salesforcedx-vscode. - scripts/ai-safeguards.js: Core engine to detect --no-verify evasion - .claude/hooks/block-no-verify.sh: Claude Code PreToolUse hook - .opencode/plugins/git-safeguards.mjs: OpenCode project-level plugin - test/aiSafeguards.test.ts: Ported unit tests
1 parent db50ada commit 5b94726

6 files changed

Lines changed: 391 additions & 0 deletions

File tree

.claude/hooks/block-no-verify.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/usr/bin/env bash
2+
# Claude adapter for the repository-owned safeguard engine.
3+
ROOT="${CLAUDE_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-$(git rev-parse --show-toplevel)}}"
4+
exec node "$ROOT/scripts/block-no-verify-cli.js"

.claude/settings.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"hooks": {
3+
"PreToolUse": [
4+
{
5+
"matcher": "Bash",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-no-verify.sh"
10+
}
11+
]
12+
}
13+
]
14+
}
15+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { createRequire } from 'node:module';
2+
3+
const require = createRequire(import.meta.url);
4+
const { noVerifyDenial } = require('../../scripts/ai-safeguards.js');
5+
6+
export const GitSafeguards = async () => ({
7+
'tool.execute.before': async (input, output) => {
8+
if (input.tool !== 'bash' && input.tool !== 'shell') return;
9+
const reason = noVerifyDenial(String(output.args.command ?? ''));
10+
if (reason) throw new Error(reason);
11+
},
12+
});

scripts/ai-safeguards.js

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
#!/usr/bin/env node
2+
/*
3+
* Copyright (c) 2025, Salesforce, Inc.
4+
* All rights reserved.
5+
* Licensed under the BSD 3-Clause license.
6+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
7+
*/
8+
9+
/*
10+
* Shared safeguard engine: detects `git commit`/`git push` invocations that pass
11+
* `--no-verify` (directly, or via shell wrappers/dynamic shell expansion), so hooks
12+
* can block them before they run. Ported from salesforcedx-vscode's
13+
* scripts/ai-safeguards.mjs, trimmed to the no-verify guard this repo needs.
14+
*/
15+
16+
const NO_VERIFY_REASON =
17+
'git with --no-verify is blocked. Run without --no-verify so hooks run.';
18+
const DYNAMIC_GIT_REASON =
19+
'Git commands assembled with shell expansion are blocked because safeguards cannot verify the resulting command. Use literal Git arguments.';
20+
const SHELL_EXECUTORS = new Set(['bash', 'sh', 'zsh']);
21+
const WRAPPERS = new Set(['command', 'env', 'sudo']);
22+
const BLOCKED_OPERATIONS = new Set(['commit', 'push']);
23+
const GIT_VALUE_OPTIONS = new Set([
24+
'-C',
25+
'-c',
26+
'--config-env',
27+
'--exec-path',
28+
'--git-dir',
29+
'--work-tree',
30+
'--namespace',
31+
]);
32+
const SUDO_VALUE_OPTIONS = new Set([
33+
'-C',
34+
'-g',
35+
'-h',
36+
'-p',
37+
'-R',
38+
'-T',
39+
'-u',
40+
'--chdir',
41+
'--group',
42+
'--host',
43+
'--prompt',
44+
'--user',
45+
]);
46+
47+
const shellSegments = (command) => {
48+
const parsed = [...command].reduce(
49+
(state, character, index, characters) => {
50+
if (state.escaped) {
51+
return character === '\n'
52+
? { ...state, escaped: false }
53+
: { ...state, escaped: false, word: `${state.word}${character}` };
54+
}
55+
if (character === '\\' && state.quote !== "'") {
56+
const escapedCharacter = characters[index + 1];
57+
return state.quote === '"' &&
58+
escapedCharacter &&
59+
!['$', '`', '"', '\\', '\n'].includes(escapedCharacter)
60+
? { ...state, word: `${state.word}${character}` }
61+
: { ...state, escaped: true };
62+
}
63+
if (state.quote) {
64+
return character === state.quote
65+
? { ...state, quote: undefined }
66+
: {
67+
...state,
68+
word: `${state.word}${character}`,
69+
dynamic:
70+
state.dynamic ||
71+
(state.quote === '"' &&
72+
(character === '$' || character === '`')),
73+
};
74+
}
75+
if (character === '"' || character === "'")
76+
return { ...state, quote: character };
77+
if (/\s/.test(character)) {
78+
return state.word
79+
? {
80+
...state,
81+
segments: [
82+
...state.segments.slice(0, -1),
83+
[
84+
...state.segments.at(-1),
85+
{ dynamic: state.dynamic, value: state.word },
86+
],
87+
],
88+
word: '',
89+
dynamic: false,
90+
}
91+
: state;
92+
}
93+
if (';&|'.includes(character)) {
94+
const segment = state.word
95+
? [
96+
...state.segments.at(-1),
97+
{ dynamic: state.dynamic, value: state.word },
98+
]
99+
: state.segments.at(-1);
100+
return {
101+
...state,
102+
segments: [...state.segments.slice(0, -1), segment, []],
103+
word: '',
104+
dynamic: false,
105+
};
106+
}
107+
return {
108+
...state,
109+
word: `${state.word}${character}`,
110+
dynamic: state.dynamic || character === '$' || character === '`',
111+
};
112+
},
113+
{
114+
segments: [[]],
115+
word: '',
116+
quote: undefined,
117+
escaped: false,
118+
dynamic: false,
119+
}
120+
);
121+
const last = parsed.word
122+
? [
123+
...parsed.segments.at(-1),
124+
{ dynamic: parsed.dynamic, value: parsed.word },
125+
]
126+
: parsed.segments.at(-1);
127+
return [...parsed.segments.slice(0, -1), last].filter(
128+
(segment) => segment.length
129+
);
130+
};
131+
132+
const gitOperation = (words) =>
133+
words.slice(1).reduce(
134+
(state, word) => {
135+
if (state.operation) return state;
136+
if (state.awaiting) return { ...state, awaiting: false };
137+
if (GIT_VALUE_OPTIONS.has(word.value))
138+
return { ...state, awaiting: true };
139+
if (word.value.startsWith('-')) return state;
140+
return { ...state, operation: word.value };
141+
},
142+
{ awaiting: false, operation: undefined }
143+
).operation;
144+
145+
const unwrapCommand = (tokens) => {
146+
const start = tokens.findIndex(
147+
(token) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value)
148+
);
149+
const command = tokens.slice(start < 0 ? tokens.length : start);
150+
if (!command.length) return { command: [] };
151+
if (command[0].dynamic) return { reason: DYNAMIC_GIT_REASON };
152+
if (!WRAPPERS.has(command[0].value)) return { command };
153+
const wrapped = command.slice(1).reduce(
154+
(state, token) => {
155+
if (state.done) return state;
156+
if (state.reason) return state;
157+
if (state.awaiting) return { ...state, awaiting: false };
158+
if (
159+
state.wrapper === 'env' &&
160+
/^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value)
161+
)
162+
return state;
163+
if (
164+
state.wrapper === 'env' &&
165+
(token.value === '-S' || token.value === '--split-string')
166+
) {
167+
return { ...state, reason: DYNAMIC_GIT_REASON };
168+
}
169+
if (state.wrapper === 'sudo' && SUDO_VALUE_OPTIONS.has(token.value))
170+
return { ...state, awaiting: true };
171+
if (token.value.startsWith('-')) return state;
172+
return {
173+
...state,
174+
command: command.slice(command.indexOf(token)),
175+
done: true,
176+
};
177+
},
178+
{
179+
awaiting: false,
180+
command: [],
181+
done: false,
182+
reason: undefined,
183+
wrapper: command[0].value,
184+
}
185+
);
186+
if (wrapped.reason) return { reason: wrapped.reason };
187+
return wrapped.command.length
188+
? unwrapCommand(wrapped.command)
189+
: { command: [] };
190+
};
191+
192+
const inspectCommand = (command, inspect) =>
193+
shellSegments(command).reduce(
194+
(state, tokens) => {
195+
if (state.reason) return state;
196+
const unwrapped = unwrapCommand(tokens);
197+
if (unwrapped.reason) return { ...state, reason: unwrapped.reason };
198+
const words = unwrapped.command;
199+
const executable = words[0]?.value;
200+
if (!executable) return state;
201+
if (SHELL_EXECUTORS.has(executable)) {
202+
const commandIndex = words.findIndex((token) =>
203+
/^-[^-]*c/.test(token.value)
204+
);
205+
const nested = words[commandIndex + 1];
206+
return commandIndex >= 0 && nested
207+
? inspectCommand(nested.value, inspect)
208+
: state;
209+
}
210+
if (executable === 'eval') {
211+
const nested = words
212+
.slice(1)
213+
.map((token) => token.value)
214+
.join(' ');
215+
return /(?:^|[;&|]\s*)(?:bash|sh|zsh)(?:\s|$)/.test(nested)
216+
? { ...state, reason: DYNAMIC_GIT_REASON }
217+
: inspectCommand(nested, inspect);
218+
}
219+
return executable === 'git'
220+
? { ...state, reason: inspect(words) }
221+
: state;
222+
},
223+
{ reason: undefined }
224+
);
225+
226+
/**
227+
* Returns a denial reason if `command` invokes `git commit`/`git push` with
228+
* `--no-verify` (directly or via shell wrappers/dynamic expansion), otherwise
229+
* `undefined`.
230+
*/
231+
const noVerifyDenial = (command) =>
232+
inspectCommand(command, (words) => {
233+
if (words.some((word) => word.dynamic)) return DYNAMIC_GIT_REASON;
234+
return BLOCKED_OPERATIONS.has(gitOperation(words)) &&
235+
words.some((word) => word.value === '--no-verify')
236+
? NO_VERIFY_REASON
237+
: undefined;
238+
}).reason;
239+
240+
module.exports = { NO_VERIFY_REASON, DYNAMIC_GIT_REASON, noVerifyDenial };

scripts/block-no-verify-cli.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env node
2+
/*
3+
* Copyright (c) 2025, Salesforce, Inc.
4+
* All rights reserved.
5+
* Licensed under the BSD 3-Clause license.
6+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
7+
*/
8+
9+
/*
10+
* Claude Code PreToolUse adapter for the no-verify safeguard. Reads the hook's
11+
* JSON payload on stdin and, if the Bash command it's about to run trips the
12+
* no-verify guard, prints a deny decision that Claude Code enforces.
13+
*/
14+
15+
const { noVerifyDenial } = require('./ai-safeguards.js');
16+
17+
const readStdin = () =>
18+
new Promise((resolveStdin) => {
19+
let data = '';
20+
process.stdin.setEncoding('utf8');
21+
process.stdin.on('data', (chunk) => {
22+
data += chunk;
23+
});
24+
process.stdin.on('end', () => resolveStdin(data));
25+
});
26+
27+
const main = async () => {
28+
const raw = await readStdin();
29+
const input = JSON.parse(raw || '{}');
30+
const command = input.tool_input?.command ?? input.command ?? '';
31+
const reason = noVerifyDenial(command);
32+
if (reason) {
33+
console.log(
34+
JSON.stringify({
35+
hookSpecificOutput: {
36+
hookEventName: 'PreToolUse',
37+
permissionDecision: 'deny',
38+
permissionDecisionReason: reason,
39+
},
40+
})
41+
);
42+
}
43+
};
44+
45+
main();

test/aiSafeguards.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright (c) 2026, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import { expect } from 'chai';
9+
10+
const {
11+
DYNAMIC_GIT_REASON,
12+
NO_VERIFY_REASON,
13+
noVerifyDenial,
14+
}: {
15+
DYNAMIC_GIT_REASON: string;
16+
NO_VERIFY_REASON: string;
17+
noVerifyDenial: (command: string) => string | undefined;
18+
} = require('../scripts/ai-safeguards.js');
19+
20+
describe('AI Git safeguards', () => {
21+
it('blocks commit and push with --no-verify', () => {
22+
expect(noVerifyDenial('git commit --no-verify')).to.equal(NO_VERIFY_REASON);
23+
expect(noVerifyDenial('git push origin feature --no-verify')).to.equal(
24+
NO_VERIFY_REASON
25+
);
26+
expect(noVerifyDenial('git -C /tmp/repo commit --no-verify')).to.equal(
27+
NO_VERIFY_REASON
28+
);
29+
});
30+
31+
it('blocks escaped and quote-concatenated forms', () => {
32+
expect(noVerifyDenial('git commit --no\\\n-verify')).to.equal(
33+
NO_VERIFY_REASON
34+
);
35+
expect(noVerifyDenial('git commit --no-veri\\fy')).to.equal(
36+
NO_VERIFY_REASON
37+
);
38+
expect(noVerifyDenial('git commit --no-veri""fy')).to.equal(
39+
NO_VERIFY_REASON
40+
);
41+
});
42+
43+
it('blocks known wrappers and nested shells', () => {
44+
expect(noVerifyDenial('command git push --no-verify')).to.equal(
45+
NO_VERIFY_REASON
46+
);
47+
expect(noVerifyDenial('env FOO=bar git commit --no-verify')).to.equal(
48+
NO_VERIFY_REASON
49+
);
50+
expect(noVerifyDenial("bash -lc 'git push --no-verify'")).to.equal(
51+
NO_VERIFY_REASON
52+
);
53+
expect(noVerifyDenial("eval 'git commit --no-verify'")).to.equal(
54+
NO_VERIFY_REASON
55+
);
56+
});
57+
58+
it('blocks dynamically assembled Git commands', () => {
59+
expect(noVerifyDenial('x=; git comm${x}it --no-verify')).to.equal(
60+
DYNAMIC_GIT_REASON
61+
);
62+
expect(noVerifyDenial('g=git; $g push --no-verify')).to.equal(
63+
DYNAMIC_GIT_REASON
64+
);
65+
});
66+
67+
it('allows safe and unrelated commands', () => {
68+
expect(noVerifyDenial('git commit -m "verified"')).to.equal(undefined);
69+
expect(noVerifyDenial('git status --no-verify')).to.equal(undefined);
70+
expect(noVerifyDenial('echo git push --no-verify')).to.equal(undefined);
71+
expect(noVerifyDenial("git commit -m 'cost $5 `literal`'")).to.equal(
72+
undefined
73+
);
74+
});
75+
});

0 commit comments

Comments
 (0)