Enforce coding-agent policy at the tool and publication boundaries with deterministic lifecycle hooks. A hook receives a typed event, evaluates versioned policy, and returns allow, deny, or require-approval before the side effect occurs. Afterward, separate hooks record evidence and verify the resulting patch. Client-side Git hooks are useful feedback, but CI and server-side controls must enforce non-bypassable gates.
This is a vendor-neutral design. Agent products expose different hook names and guarantees, so first map their callbacks to the lifecycle below and confirm whether a callback can actually prevent execution.
Put hooks around side effects
Prompts can explain policy; they cannot enforce it. Place checks in the process that owns the capability:
task accepted
-> before workspace
-> before read
-> before command
-> before write
-> after tool
-> before commit
-> before publish
-> after publish
-> cleanup
Not every read needs a separate hook in a small local assistant. The essential rule is that a sensitive action cannot happen if the controlling policy engine is unavailable or bypassed.
Use early hooks for cheap rejection. For example, deny a path outside the repository before opening it, not after logging its contents. Use later hooks for facts only available after execution, such as the independently observed file set or verification result.
Define a stable hook protocol
Send structured data rather than shell snippets:
type HookEvent =
| {
kind: "before-command";
runId: string;
sequence: number;
executable: string;
argv: string[];
cwd: string;
requestedCapabilities: string[];
policyVersion: string;
}
| {
kind: "before-publish";
runId: string;
sequence: number;
destination: string;
baseCommit: string;
headCommit: string;
patchSha256: string;
verificationArtifact: string;
policyVersion: string;
};
type HookDecision =
| { decision: "allow"; obligations: string[] }
| { decision: "deny"; ruleId: string; publicReason: string }
| {
decision: "require-approval";
ruleId: string;
approvalRequestId: string;
actionDigest: string;
};
Validate events at the boundary. Reject unknown kinds, missing fields, duplicate sequence numbers, invalid paths, and unsupported policy versions. Canonicalize the exact action before hashing it; approval must be invalidated when the command, destination, base, head, or patch changes.
Return a public reason that helps the developer and a private evidence artifact for authorized operators. Do not reveal secret values or sensitive path contents in denial messages.
Make policy deterministic
Hooks should evaluate facts, not ask a model whether an action “seems safe.” Useful inputs include:
- normalized executable and argument vector;
- canonical path and resolved symlink target;
- requested network destination;
- credential capability and scope;
- changed paths and patch digest;
- branch, repository, and publication destination;
- verification and approval artifacts;
- actor and environment;
- data classification.
Examples of deterministic rules:
- deny writes outside the assigned repository roots;
- deny reads of
.env, key stores, browser profiles, and credential directories; - deny network by default; allow exact package registries for an install phase;
- require approval for workflow, ownership, authentication, migration, or deployment changes;
- require a fresh verification artifact for the exact patch digest;
- deny publication to a protected branch from an agent credential;
- cap command time, output, processes, changed files, and artifact bytes.
Keep model-based classification advisory. If a model labels a change “documentation only,” the hook must still derive changed paths and executable effects independently.
Use fail-closed behavior according to risk
If the policy engine times out before a production deployment, deny the action. If a local optional telemetry hook fails after a read-only search, continuing may be reasonable.
Define behavior per hook:
| Hook | Timeout action | Retry | Required evidence |
|---|---|---|---|
| before-command | deny for privileged command | bounded, no side effect yet | policy decision |
| before-write | deny | bounded | canonical path decision |
| after-tool | record degraded telemetry | queue locally | result digest |
| before-publish | deny | bounded | patch, approval, verification |
| cleanup | isolate workspace and alert | bounded | cleanup outcome |
Never silently switch from fail-closed to fail-open. Surface degradation to the user and audit system.
The policy service itself needs availability limits; otherwise it becomes a denial-of-service choke point. Cache only decisions whose complete inputs and policy version are in the key. Never cache a broad approval such as “this run may publish.”
Keep command execution free of shell injection
A before-command hook should receive executable and argv exactly as the process runner will use them. Policy that evaluates one string while execution invokes a shell creates a check/use mismatch.
Use a direct process API, an absolute or policy-resolved executable, a controlled environment, and a fixed working directory. If a shell is genuinely required, treat the entire script as code: run it in a sandbox with no secrets or network by default and require stronger approval.
Validate paths after normalization and symlink resolution. Re-check at execution time or operate through directory handles when the platform permits, because a writable directory can create time-of-check/time-of-use races.
Generated package scripts, compiler plugins, test fixtures, and Git hooks can execute code indirectly. Allowlisting npm test is not equivalent to allowlisting every program it launches; sandbox the process tree.
Treat after-hooks as evidence, not retroactive safety
An after-write hook can compute changed paths, run git diff --check, scan for secrets, and queue verification. It cannot undo a secret already sent over the network. Preventive checks belong before the capability.
Useful after-tool evidence includes:
- validated input digest;
- exit code and termination reason;
- stdout/stderr artifact digests and truncation flags;
- process and network policy outcome;
- workspace mutation summary;
- start/end timestamps;
- hook and policy version.
Write events append-only with per-run sequence numbers. Store large outputs in encrypted, access-controlled artifacts. Apply retention and redaction rules; an audit log that copies every command result may become a secret database.
Bind approval to the exact action
For a high-risk event, return require-approval with a canonical action digest. The review UI should render from validated structured fields:
- actor and task;
- action and destination;
- patch or command;
- data and credential scope;
- verification evidence;
- expiry and policy version.
At execution, recompute the digest and verify approver authorization, expiry, single-use status, and current policy. If anything changed, request new approval. Human Approval for AI Agents develops this pattern in detail.
Do not let the same agent create, approve, and execute its own exception. Emergency override needs a named human, narrow scope, expiry, reason, and post-event review.
Use Git hooks for feedback, not sole enforcement
Git's hooks documentation describes local hooks such as pre-commit and pre-push and the effect of a nonzero exit status. It also documents bypass options for relevant client-side hooks. A developer or agent that controls the checkout may modify, disable, or bypass those hooks.
Use local hooks to catch:
- formatting and generated-file drift;
- obvious secret patterns;
- missing task metadata;
- fast path-policy errors.
Repeat required checks in a protected system the agent cannot rewrite: CI, the repository host, or a deployment controller. Protect the workflow definition itself through ownership and review.
GitHub's CODEOWNERS documentation explains path-based review requests. Use repository ownership plus branch protection or rules appropriate to the host; do not assume ownership alone blocks a merge.
Secure CI policy evaluation
Pull-request code is untrusted. A workflow that checks out and executes an untrusted patch while holding write credentials creates a direct privilege path.
GitHub's secure use guidance for pull_request_target warns about running untrusted code in a privileged context. Separate untrusted build/test jobs from privileged publication jobs. Transfer only validated, content-addressed artifacts and minimal status—not a writable workspace or arbitrary script.
Pin third-party automation according to the repository's supply-chain policy, minimize token permissions, protect secrets, and avoid printing sensitive contexts. GitHub's broader secure use reference documents risks and hardening controls for workflows.
Secret scanning is defense in depth, not permission to expose secrets. GitHub's push protection documentation describes blocking detected secrets before they reach a remote. Keep credentials absent from the agent workspace whenever possible and revoke any credential that is exposed even if a hook blocks the push.
Map a practical lifecycle
Before workspace: validate repository, commit, task authority, and resource limits.
Before read: enforce roots and secret/data classification; cap bytes.
Before command: validate executable, arguments, environment, network, credential, and limits.
Before write: enforce canonical path, file type, size, and ownership.
After tool: capture outcome and mutation evidence.
Before commit: check observed paths, diff integrity, generated files, and local fast tests.
Before publish: bind approved patch, successful trusted verification, destination, and credential.
After publish: record remote object identifier and verify it matches the approved digest.
Cleanup: terminate descendants, revoke temporary credentials, retain approved artifacts, and remove the exact disposable workspace.
Make each phase idempotent where possible. A retried after-publish hook should discover the existing remote object by idempotency key rather than creating a duplicate pull request.
Test the policy boundary
Add regression cases that attempt to:
- traverse paths and escape via symlinks;
- replace an executable after approval;
- smuggle shell metacharacters through arguments;
- launch children or package scripts;
- alter a patch after approval;
- publish with stale verification;
- bypass a local Git hook;
- change the CI workflow that enforces policy;
- exfiltrate through logs, DNS, redirects, or artifacts;
- replay a hook event or approval;
- force policy timeout;
- leave a process alive after cancellation.
Assert an actual denial or contained failure at the enforcement layer. A model refusal is not a passing security test.
Implementation checklist
- Hook events and decisions use validated, versioned schemas.
- Every side effect is controlled by the component that owns its capability.
- Decisions derive from canonical facts, not model self-classification.
- High-risk pre-hooks fail closed on timeout or policy error.
- Command checks and execution use the same argument-vector representation.
- Approval binds actor, action, patch, destination, policy, and expiry.
- After-hooks store digests and redacted evidence, not unlimited secret-bearing logs.
- Client Git hooks are repeated in protected CI/server enforcement.
- Privileged workflows never execute untrusted pull-request code.
- Adversarial bypass, replay, cancellation, and degradation tests are retained.