Can script approval for AI agents trust a signed interpreter?
Script approval for AI agents needs more than a signed interpreter. Review the exact source bytes, execution context, dependencies, and action target.

A signed interpreter does not prove which script you approved. It proves something narrower: the operating system launched a particular interpreter binary whose signature it could validate. The interpreter may then read any number of mutable files, accept source from a command string, load code through a package resolver, and act with credentials that the script never held itself.
That distinction gets lost when an approval card says "Python" or "Node" and shows a reassuring signing badge. I have seen reviewers approve that prompt because the binary name was familiar, then spend the unpleasant part of the afternoon finding out which repository checkout, symlink, environment setting, or package preload actually supplied the code. Familiar executables deserve trust appropriate to executables. They do not make arbitrary source trustworthy.
A signed interpreter only identifies the interpreter
Code signing answers a provenance question about an executable file. On macOS, the codesign tooling can inspect an executable's signature and designated requirement. Gatekeeper and the platform's runtime protections use related information when they assess software. None of that mechanism asserts that a Python file passed on the command line came from the same developer, that it has not changed since review, or that its imports are benign.
Consider these two invocations:
/usr/bin/python3 /Users/dev/work/release/publish.py
/usr/bin/python3 -c "import os; os.system('curl ...')"
The interpreter identity can be identical in both cases. The source identity is completely different. The first call has a path that might help a reviewer find code. The second has no script file at all. A prompt that reduces either call to "signed Python requests network access" discards the part a human needs to judge.
The Python documentation describes distinct command-line forms for running a file, executing a command with -c, running a module with -m, and reading source from standard input. That is normal interpreter behavior, not a defect. The mistake is treating those forms as if they all had one stable, signed program behind them.
The same error appears with process identity. An approval system can correctly tell you that an agent process came from a code-signing authority you recognize. It still cannot infer that every local file the process asks an interpreter to execute deserves the same approval. Caller identity and source identity answer different questions:
- Caller identity asks who launched the request.
- Interpreter identity asks which binary parses the source.
- Source identity asks which bytes the interpreter will parse.
- Action identity asks which host, API route, account, or command receives the resulting request.
If a review screen has room for only the first two, it gives a false sense of specificity. In practice, the source and action decide whether the request is acceptable.
The review target is an execution tuple
A reviewer needs a stable description of the exact execution, not a friendly label. I call that description an execution tuple: the resolved interpreter, its arguments, the source artifact, the execution context, and the requested outside action. Change a material member of the tuple and you have a different execution that needs a fresh decision.
For a file-based Python call, the minimum useful tuple looks like this:
{
"caller": {
"pid": 48172,
"signing_authority": "Example Development Team"
},
"interpreter": {
"resolved_path": "/usr/local/bin/python3.12",
"signing_identity": "Python Software Foundation",
"sha256": "c44e...9a10"
},
"argv": ["/usr/local/bin/python3.12", "/private/var/run/gateway-src/publish.py"],
"source": {
"display_path": "/Users/dev/work/release/publish.py",
"sha256": "6ab1...ee42"
},
"context": {
"working_directory": "/Users/dev/work/release",
"environment": {"DEPLOY_ENV": "staging"}
},
"requested_action": "POST https://api.example.invalid/releases"
}
The digest abbreviations belong in the approval card, but the full digest belongs in the journal. A reviewer usually needs the readable path and a diff or source preview. An investigator needs an unambiguous value that can be compared later.
Do not put every environment variable into the card. That turns a decision into an eye test. Capture values that affect code selection, command resolution, credentials, proxy routing, target selection, and feature switches. For Python that may include PYTHONPATH, PYTHONHOME, and an explicitly supplied configuration path. For shell execution it often includes PATH, the current directory, and variables interpolated into the command. Record the complete environment in protected audit data if your threat model calls for it, while presenting the material subset to the human.
This is also where teams blur a second distinction: reproducibility is not authorization. A lockfile, a Git commit, or a container image can help reproduce what ran. It does not say whether that code should call production, delete a remote branch, or open an SSH session. Pair source identity with a clear action request.
Python can hide code behind ordinary launchers
Python makes a plain file invocation look simpler than it is. python deploy.py tells you where execution begins, but the interpreter can import modules from the script directory, installed packages, configured search paths, and code selected by application logic. A virtual environment can also change which interpreter an unqualified python resolves to.
Start by resolving the executable before you evaluate its signature. The label python, python3, or venv/bin/python is not an identity. A launcher can be a symlink, a shim, or a different binary after a toolchain update. The gateway should resolve the object the kernel will launch, inspect that object, and record its path and digest.
Then handle the source path as a display aid, not the security boundary. Resolve symlinks to a canonical location for the review record, but do not execute the mutable original after the review. A repository checkout can replace deploy.py without changing the path. A symlink can point at a different target. A path check catches neither event by itself.
A practical sequence is:
- Read the requested script bytes and calculate SHA-256.
- Copy those bytes to a private, gateway-owned directory with restrictive permissions.
- Display the caller's path, the canonical path, the digest, and a source preview for approval.
- Invoke the verified interpreter with the private copy, then record the result against that digest.
That copy is not busywork. It closes the time-of-check to time-of-use gap. If the reviewer approved digest 6ab1...ee42, the interpreter must read the bytes whose digest is 6ab1...ee42. Hashing the repository file and then asking Python to reread the repository file later leaves a small but real window for a replacement.
Imports still need a decision. If publish.py imports a local release_helpers.py, a changed helper can change behavior even when the entry file stays fixed. The strict option is a source manifest that includes every local module permitted for that execution. A more practical option for routine work is to stage the entry script and its declared local package tree together, reject imports outside that staged tree, and require a new approval when the manifest digest changes.
Do not pretend that this catches dynamic imports, native extensions, sitecustomize, or arbitrary code fetched at runtime. It does not. The approval screen should name those allowances when they exist. A script that says importlib.import_module(os.environ["PLUGIN"]) has not earned the same broad approval as a self-contained script merely because both begin with the same signed interpreter.
Node's entry file is only one part of the program
Node adds a different layer of ambiguity. node task.js has an entry file, yet module resolution can select code through package.json, package exports, lockfiles, symlinks, and the current directory. The Node CLI documentation also describes preloads such as --require and --import; these can run code before the entry file begins.
That means a review system must show the full argument vector, not just the final .js path. These calls deserve different scrutiny:
node tools/publish.mjs
node --import ./tools/setup.mjs tools/publish.mjs
node --require ./tools/patch.cjs tools/publish.mjs
node -e "require('child_process').execSync(process.argv[1])" "git push --force"
A reviewer who sees only tools/publish.mjs misses code that runs earlier in the second and third calls. In the last call, there is no reviewed entry file. The command string is the source artifact and must be displayed, stored, and hashed as such.
Node's NODE_OPTIONS environment variable deserves equal treatment. Node documents it as a way to pass permitted command-line options through the environment. If a process can supply preloads or debugging behavior there, a gateway that ignores it has inspected an incomplete command. You do not need to frighten reviewers with every runtime setting, but you must surface ones that cause code to load or alter target selection.
Package managers create another trap. npm run publish often feels like a named task, but the behavior comes from a mutable package.json, its scripts, the lockfile, package-manager hooks, and binaries found in the project dependency tree. A task name should expand before approval. Show the resolved command, the project revision or staged manifest, and every lifecycle hook that will execute. If that expansion is unavailable, ask for a narrower approval or refuse the request. "Run package script" is not a meaningful action description when the package file can change beneath it.
For repeatable Node automation, stage a reviewed workspace snapshot or use an immutable build artifact. A hash of publish.mjs alone is fine only when the script has no local dependencies and no preload path. Most nontrivial projects do not meet that condition.
Shell strings need source treatment
Shell requests fail review when someone calls them commands instead of programs. sh -c parses a source string with expansions, substitutions, redirects, pipelines, functions, and command lookup. The string may be short, but it can invoke an unlimited sequence of other programs.
Compare these requests:
/bin/sh -c 'curl -fsS "$RELEASE_URL" | sh'
/bin/sh /private/var/run/gateway-src/release.sh
The first request needs the exact command string, every material environment value, and an explanation of what the downstream program receives. The second needs the same script path and content treatment as Python or Node. The signature on /bin/sh tells you who supplied the parser. It does not bless either source input.
Do not approve a shell command from its first verb. git status may be harmless in one exact argument vector, while git -c credential.helper=... changes the inputs that Git will load. curl could fetch data, write a file, or pipe bytes into another interpreter. A reviewer needs enough of the syntax to see redirection and substitution, and enough of the execution context to see where programs resolve.
PATH is often overlooked. A script that invokes deploy without an absolute path delegates executable selection to the environment. If the request comes from an agent workspace, an attacker who can alter that workspace may place a program earlier in PATH. Capture the resolved executable path for each security-sensitive subcommand when possible. When full shell evaluation would itself be unsafe or too uncertain, use a constrained command interface instead of trying to build a perfect shell parser.
That last point argues against a popular recommendation: "Just allow a signed shell and prompt on each call." It is popular because the shell exists everywhere and approval seems simple. It is wrong because a single approval has no stable source object unless the system records the exact string or immutable script and the material context. Per-call approval can still approve the wrong thing with admirable consistency.
A content hash needs a file it can actually bind
A hash is evidence about bytes, not proof that the intended program will use them. The implementation must bind the reviewed bytes to execution. This is the place where many otherwise careful designs break.
The unsafe pattern is easy to recognize:
1. Read /workspace/scripts/publish.py
2. Calculate and display SHA-256
3. Wait for approval
4. Run python /workspace/scripts/publish.py
Between steps 2 and 4, another process can edit the file, replace a symlink, or change a mounted directory. The approval record remains accurate about what the reviewer saw and useless about what ran.
Use one of these models instead:
- Copy the reviewed bytes into a private execution directory, set permissions so the requesting process cannot alter them, and run the copy.
- Execute a previously built immutable artifact whose digest was reviewed and recorded.
- Hold an already opened file object through the check and execution path only if the interpreter and operating system let you execute that exact object without re-resolving a mutable path.
The private-copy model is usually easier to explain and audit. It also gives you a stable artifact for incident review. Preserve the original display path as context, because people need to know which project file prompted the run, but do not confuse it with the bytes that executed.
Content hashing has limits that should stay visible. It cannot decide whether source is safe. It cannot stabilize remote responses, time-dependent behavior, random values, or code loaded after the hash. It does prevent a particular class of approval mistake: reviewing one local script revision and executing another. That is a worthwhile boundary, provided you describe it honestly.
Use SHA-256 or another current cryptographic digest with a fixed encoding and always include the algorithm name in records. A bare hexadecimal string invites future confusion. A digest record should say sha256:6ab1...ee42, not merely 6ab1...ee42.
Approval cards should show the evidence a person can use
A good approval card lets a reviewer decide quickly without hiding the facts that change the decision. Do not lead with a file hash. Humans cannot assess a hash by sight. Lead with the requested action and the caller, then show the interpreter, source location, source state, and material execution context.
For a deployment request, a compact card might read like this:
Caller: signed process from Example Development Team, PID 48172
Action: POST release data to api.example.invalid
Interpreter: /usr/local/bin/python3.12, signed by Python Software Foundation
Source: /Users/dev/work/release/publish.py
Reviewed bytes: sha256:6ab1...ee42
Execution copy: /private/var/run/gateway-src/6ab1...ee42/publish.py
Context: DEPLOY_ENV=staging, working directory /Users/dev/work/release
The card should offer the source preview or a diff against the last approved digest. A diff is often better for repeated work because it directs attention to the changed lines. Still provide the complete source on demand. A misleadingly cropped preview is worse than no preview.
Avoid vague approval labels such as "Allow deployment tooling" or "Allow Python access." They teach people to click through on brand recognition. A decision should state its lifetime too. One execution, one process session, and a reviewed artifact release are different scopes. A session approval for an agent process can reduce prompt fatigue, but any script whose digest changes should trigger a new source decision before it can reuse outside authority.
This differs from a rules engine. You do not need a language for people to write conditions such as "allow safe scripts." You need a fixed review object that cannot quietly broaden after approval. The system should construct that object from resolved inputs, display it, and bind execution to it.
Dependency boundaries must be explicit
An entry script digest can be sufficient only when the program's code boundary is genuinely that one file. Treat that as an exception, not the default. Python imports, Node modules, shell source commands, templates, configuration files, and executable plugins can all change behavior after the entrypoint passes review.
Set the boundary according to the action's consequence. For a low-risk read against a development service, you may accept a staged entry script plus an explicit statement that it can import installed packages. For a production write or an SSH command, include local source dependencies in a manifest, pin external dependencies, and reject runtime downloads of executable code. The record then tells an investigator what you meant by "the script."
A simple manifest can contain relative paths and digests:
sha256 publish.py 6ab1...ee42
sha256 release_helpers.py 9d07...1a3c
sha256 config/targets.json 743e...64b1
The gateway should calculate this manifest from staged copies or a controlled build input, rather than accepting a manifest supplied by the same mutable workspace. If a project declares a lockfile as part of its boundary, hash that lockfile too. A lockfile only helps when the runtime honors it and the reviewed process cannot substitute a different dependency tree.
There is a point where interpreter-based local execution becomes too broad for a one-click decision. If code can discover plugins from arbitrary directories, fetch and execute remote content, or write then run generated scripts, split the work. Approve a build that produces an immutable artifact, inspect the artifact's declared action, then approve that action. The extra boundary is cheaper than reconstructing an accidental production change.
Logs must answer what ran, not what the UI called it
When an action goes wrong, the first useful question is usually "what exactly ran with that authority?" A journal entry that says "Python approved" cannot answer it. Retain the execution tuple, the decision scope, the reviewer interaction, timestamps, and the observed result. Redact secrets from the record, but do not redact the identity of the source artifact or the target action.
A sound record links related events. The session entry identifies the agent process and its authority. The action entry identifies the interpreter, staged source digest, arguments, target, and result. If a reviewer revokes a session, that event should connect to the same session identity. Otherwise, operators cannot tell whether revocation stopped the requester that made the call.
Tamper evidence changes the quality of the record. A hash-chained journal can make later alteration detectable, but it does not repair missing fields. Verify the chain and still ask whether it records the resolved path, source digest, context, and actual action. Integrity preserves evidence; it does not create evidence that the system never captured.
Sallyport's split journals are useful here because they separate the agent run from individual HTTP or SSH calls while projecting both from one encrypted, hash-chained audit log. Its sp audit verify command can verify the chain offline over ciphertext, which is the right property for checking whether retained evidence changed after the fact.
Treat source changes as new authority
The safest default is simple: when the source digest changes, require a new decision for an outside action. Do not silently carry a previous script approval forward because the path, interpreter, project name, or process signature looks familiar.
That rule will create a few more prompts during active development. It should. Code review changes authority when the code can spend a credential, mutate a remote service, or run an SSH command. The answer is not to suppress every prompt. Improve the review artifact, stage deterministic source, and grant the broader process session only where the source remains independently identified.
For teams using an action gateway, keep the gate focused on the point where credentials leave the local machine. The agent should request an HTTP call or SSH command with a source-bound review record, while the gateway holds the credential and returns the result. That arrangement avoids handing secrets to a mutable script, but it still requires honesty about which code requested the action.
Start with your most sensitive interpreter call. Resolve the binary, display the complete arguments, hash and stage the actual source bytes, record the material context, and make a changed digest mean a changed approval. Once that record exists, a signed interpreter becomes useful evidence in a complete decision instead of a reassuring label over an unknown script.
FAQ
Does a signed Python or Node binary make a script safe to approve?
No. A signature tells you who signed the interpreter binary, such as Python, Node, or a shell. It says nothing about which source file that binary will read, whether that file changed, or which files the source will load next.
What should an approval prompt show for a script?
Approve an exact execution record: the resolved interpreter, its signing identity, the argument vector, the canonical script location, and the script digest. Add the working directory, relevant environment values, and declared dependency inputs when they can alter behavior.
Is checking a script path enough for agent approval?
A path is useful for a person, but it is not an identity. A script can be edited in place, replaced through a symlink, or checked out to a different revision at the same path, so pair the path with a content digest.
How do I avoid a hash check race condition?
Hash the exact bytes that will run, then execute an immutable gateway-owned copy of those bytes. If you hash one file and later execute the original mutable path, a write between those events defeats the check.
Can imported modules change an approved script's behavior?
Python can load imports through the normal import system, startup hooks, and explicit runtime loaders. Node can preload modules and resolve package entry points, so reviewers should treat the entry script as the beginning of the review boundary, not the whole boundary.
How should I review a shell -c command?
Treat the string as source, not as an argument. Store the exact bytes of the command string, hash them, display them in the approval card, and record the shell executable and environment that will interpret them.
Do Python virtual environments change interpreter identity?
A virtual environment may point to the same interpreter through a launcher or symlink, or it may use a distinct binary. Resolve the executable that the operating system will launch and verify that object, rather than trusting the command label or the virtual environment directory name.
Can process signing replace a script hash?
No. A process signature can help establish who produced the caller, while a script digest identifies what that caller asked an interpreter to execute. Both belong in a high-consequence approval, and neither replaces the other.
Should a team permanently approve a deployment script path?
For repeated automation, approve a narrow, immutable artifact or a reviewed release revision with an expiry and explicit action scope. A permanent approval for any future file at a familiar path turns normal repository edits into credential authority.
What must an audit log retain for approved scripts?
Keep an audit record that includes the decision, the caller identity, the interpreter identity, the exact command, the source digest, the action target, and the result. A log that says only "approved Python" cannot explain what happened after an incident.