构建 GitHub Copilot preToolUse Hook 拦截危险命令
Original title: Build a GitHub Copilot preToolUse hook that blocks risky commands
The title and summary in the selected language are awaiting translation.
文章给出一个 GitHub Copilot 仓库级 preToolUse Hook 的完整实现,用 Node.js 脚本读取标准输入的工具调用参数,通过正则匹配 rm -rf、git reset --hard、git clean -f 等破坏性命令并返回 deny 决策。
GitHub Copilot hooks run external commands at defined points in an agent session. A preToolUse hook runs before a tool executes and can allow, deny, ask, or modify that tool call.
That makes it useful for a narrow class of controls: rules that can be decided from the tool name and structured arguments before execution. It does not turn a string-matching script into a complete security boundary. Sandboxing, least-privilege credentials, branch protection, code review, and CI still own the larger risk.
This guide builds one repository hook that denies an intentionally small list of destructive shell-command shapes. The example uses Node.js so one script can run anywhere the repository already provides a compatible Node runtime.
What you will add
.github/
hooks/
shell-policy.json
scripts/
copilot/
pre-tool-use.mjsThe JSON file tells Copilot when to invoke the policy. The script reads the hook payload from standard input and writes one compact JSON decision to standard output.
1. Configure the repository hook
Create .github/hooks/shell-policy.json:
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"matcher": "bash|powershell",
"command": "node scripts/copilot/pre-tool-use.mjs",
"cwd": ".",
"timeoutSec": 5
}
]
}
}Why these fields matter:
version: 1selects the documented hook configuration format.preToolUseuses the camel-case payload shape:toolNameandtoolArgs.matcheris an anchored regular expression over the runtime tool name. This one handlesbashandpowershell.commandis the cross-platform fallback. Copilot cloud agent honorsbashorcommand, but notpowershellentries.cwd: "."makes the repository root the script’s working directory.timeoutSecis deliberately short because a policy hook should not perform network I/O.
Repository hooks live under .github/hooks/*.json. For cloud agent, the hook must be present on the default branch before the job starts.
2. Implement the policy script
Create scripts/copilot/pre-tool-use.mjs:
import process from "node:process";
const input = await readStdin();
const payload = JSON.parse(input);
const toolArgs = parseToolArgs(payload.toolArgs);
const command = typeof toolArgs.command === "string" ? toolArgs.command : "";
const deniedPatterns = [
/(^|\s)rm\s+-[^\n]*r[^\n]*f(?:\s|$)/i,
/(^|\s)git\s+reset\s+--hard(?:\s|$)/i,
/(^|\s)git\s+clean\s+-[^\n]*f(?:\s|$)/i,
/(^|\s)(?:Remove-Item|del)\b[^\n]*(?:-Recurse|-Force|\/s|\/q)/i,
];
const deniedPattern = deniedPatterns.find((pattern) => pattern.test(command));
if (deniedPattern) {
writeDecision({
permissionDecision: "deny",
permissionDecisionReason:
"Repository policy blocks destructive cleanup commands. Ask a maintainer to perform or approve this operation.",
});
}
async function readStdin() {
let value = "";
for await (const chunk of process.stdin) value += chunk;
return value;
}
function parseToolArgs(value) {
if (value && typeof value === "object") return value;
if (typeof value !== "string") return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function writeDecision(decision) {
process.stdout.write(JSON.stringify(decision));
}The script handles toolArgs as either an object or a JSON string because fixtures and integrations can serialize the field differently. It emits exactly one JSON object. Do not write debug messages to stdout: Copilot parses the remaining stdout as one JSON value. Send diagnostics to stderr instead.
3. Test the policy without an agent
Start with a harmless fixture.
On PowerShell:
'{"timestamp":1704614400000,"cwd":"C:\\repo","toolName":"powershell","toolArgs":{"command":"npm test"}}' |
node scripts/copilot/pre-tool-use.mjsExpected output: no output. An empty result leaves the normal Copilot permission flow in control.
Now test a denied command:
'{"timestamp":1704614400000,"cwd":"C:\\repo","toolName":"powershell","toolArgs":{"command":"git reset --hard HEAD~1"}}' |
node scripts/copilot/pre-tool-use.mjsExpected output begins with:
{
"permissionDecision": "deny",
"permissionDecisionReason": "Repository policy blocks destructive cleanup commands..."
}On a POSIX shell, use the same fixtures with printf:
printf '%s' '{"timestamp":1704614400000,"cwd":"/repo","toolName":"bash","toolArgs":{"command":"rm -rf dist"}}' \
| node scripts/copilot/pre-tool-use.mjsAdd these fixtures to your normal test runner if the policy becomes important. A hook script is production logic: a regex edit can change what developers and agents are allowed to run.
4. Test in Copilot CLI and cloud agent
Local fixtures prove only that the script parses and decides as expected. They do not prove discovery or runtime behavior.
For Copilot CLI:
- Start a new CLI session after adding the hook.
- Ask Copilot to run a harmless command and confirm it proceeds.
- In a disposable repository, ask it to propose one of the denied commands.
- Confirm the tool call is denied and the reason reaches the agent.
- Test on every operating system used by the team.
For cloud agent:
- Merge the hook and script into the default branch.
- Make sure Node is available in the cloud environment before relying on this implementation.
- Run a job against a disposable branch or repository.
- Remember that cloud agent is non-interactive: a
preToolUseresult of"ask"is treated as"deny".
Cloud agent runs in an ephemeral Linux sandbox with restricted outbound networking. It ignores powershell hook entries. A cross-platform command works only if the referenced runtime exists in that environment.
The failure behavior is part of the policy
The hook transport changes what happens when the policy itself fails.
For documented preToolUse behavior:
- A command hook that crashes or exits non-zero is fail-closed and denies the tool call.
- A command hook that times out is fail-open to the normal permission flow.
- An HTTP hook that encounters a network error, timeout, or non-success response is fail-open.
- An explicit
permissionDecision: "deny"blocks the tool. - Empty output preserves the normal permission flow; do not return
"allow"as a fallback.
That asymmetry matters. Do not describe an HTTP policy service as fail-closed when the documented network-failure behavior is fail-open. If a control must remain available during an outage, keep the decision local or enforce it at a lower layer.
Keep pre-tool policies fast and deterministic. A five-second timeout that allows normal processing is not protection against a slow remote authorization service.
Are command-string regexes enough?
The example catches obvious command shapes. It can be bypassed by aliases, scripts, alternate tools, encoded commands, language runtimes, or a command pattern you did not anticipate. Expanding the regex forever creates a brittle shell parser.
Use stronger controls for stronger requirements:
- Remove credentials the agent does not need.
- Run in a sandbox with a restricted filesystem and network.
- Expose narrow tools instead of arbitrary shell access.
- Protect default branches and production environments.
- Require review and CI for state-changing work.
- Use an allowlist when the permitted command set is small and stable.
A useful hook reduces accidental risk and gives the agent an immediate explanation. It does not make arbitrary command execution safe.
When should you use other hook events?
preToolUse is not the only event, but it is the right event for a decision that must happen before execution.
| Need | Event | Important limit |
|---|---|---|
| Allow, deny, or modify a tool call | preToolUse | Cloud treats ask as deny |
| Integrate with CLI permission handling | permissionRequest | CLI only; cloud tool calls are pre-approved |
| Add context or transform a successful result | postToolUse | Validate output size and data exposure |
| Provide recovery context after a tool failure | postToolUseFailure | Do not hide the original failure |
| Inject startup context | sessionStart | Prompt-hook behavior differs in non-interactive runs |
| Notify a local user | notification | Does not fire in cloud agent |
Check the hooks reference before adopting an event. The available events, payload aliases, and cloud behavior are part of a versioned product surface.
A production review checklist
Before merging a repository hook, review it like any other policy code:
- Is the event supported in every target Copilot surface?
- Does the matcher use the correct runtime tool names?
- Does the script accept the documented payload shape?
- Is stdout exactly one valid JSON decision?
- Are allow, deny, malformed input, crash, and timeout behavior tested?
- Does the cloud environment contain every runtime the hook invokes?
- Can developers understand why a tool was blocked?
- Is the same critical boundary enforced outside the agent workflow?
The useful outcome is not “the repository has hooks.” It is a small, tested policy whose behavior is understood when the agent, script, network, or execution environment fails. Pair hooks with the validation checklist so every automated decision produces evidence a reviewer can inspect.
Source: DevAgentStack · Field Notes · devagentstack.com