A Consent Scheme source file can be marked executable and run directly from a
shell with a #! (shebang) line. This is the non-interactive twin of the
portable terminal REPL shell: the REPL runs forms typed at a
prompt, an executable script runs forms from a file. Together they make the
runtime a usable program — interactive at the prompt, scriptable from the shell.
This document covers how a shebang reaches the interpreter, the two recommended shebang forms and their tradeoffs, how the runtime recognizes and skips the shebang line, exit-status and stream behavior, and the noninteractive fail-closed policy posture a script inherits.
When you run ./script.scm, the operating-system kernel reads the first line.
If it begins with #!, the kernel treats the rest of that line as an interpreter
command and runs interpreter [args] ./script.scm. The interpreter then opens
the file and reads the whole thing — including the #! line it was launched
from, which the kernel does not strip. So the interpreter's reader has to skip
its own shebang line; that is what this feature adds (issue #399).
#! is not free in Scheme: it also prefixes reader directives (#!fold-case),
version flags (#!r6rs), named values (#!eof), and DSSSL keywords
(#!optional). So the runtime recognizes a shebang only narrowly — a #!
that is the first two bytes of the file followed by / or whitespace — and
consumes it at the script-loading boundary. Everywhere else, #!fold-case and
every other #!-token keep their normal reader meaning.
#!/usr/bin/env consent
(import (scheme base) (scheme write))
(display "hi\n")consent FILE runs FILE as a script — the same as consent --script FILE, so
no flag is needed in the shebang. Make it executable and run it:
chmod +x script.scm
./script.scmThis requires the consent binary to be found by env on your PATH. The
host-compiled binary is built at build/compile/<host>/bin/consent (see
development.md); install or symlink it onto your PATH as
consent, or write the shebang with an absolute path:
#!/absolute/path/to/consentWhy no --script flag. A flagged shebang such as
#!/usr/bin/env consent --script is fragile across systems. On Linux the kernel
passes everything after the interpreter path as a single argument, so env
receives the one token "consent --script" and fails to find a program by that
name. (macOS and the BSDs word-split the line, so the flagged form happens to
work there — which hides the bug on those machines.) Passing no flag avoids the
kernel's single-argument rule entirely, the same way #!/usr/bin/env python
works. --script FILE still works when you invoke the binary yourself; it is
just a poor fit for a shebang line.
When the interpreter is not installed on PATH under a fixed name and you
want the script to locate it itself, use the /bin/sh polyglot. It is valid
both as a shell script and as a Consent Scheme program:
#!/bin/sh
#|
exec consent --script "$0" "$@"
|#
(import (scheme base) (scheme write))
(display "hi\n")It runs in two passes, and each program only reads part of the file:
- The shell. The kernel sees
#!/bin/shand runs/bin/sh ./script.scm. To the shell,#!/bin/shand#|are ordinary#comment lines, andexec consent --script "$0" "$@"is a command that replaces the shell process with the interpreter, pointed at this same file. The shell never reaches|#or the Scheme code below it. - The interpreter. Consent Scheme reads the same file as Scheme: it skips
the
#!/bin/shshebang, reads#| exec … |#as a block comment — which hides the shell line from Scheme — and runs the program.
The polyglot's genuine advantages are that the kernel only ever needs /bin/sh
(always at a fixed path), the shell's word-splitting sidesteps the single-argument
rule so a flag like --script survives, and you can run real shell logic
(PATH setup, fallbacks, a path computed relative to the script) to find the
interpreter. It is not a way to avoid locating the interpreter: the exec
line still has to name something the shell can find, on PATH or by absolute
path.
A script's program output goes to standard output; the runtime keeps its own
records and diagnostics off that stream, so a scripted consumer of program
output is never corrupted. A script ends successfully with exit status 0. An
uncaught error fails the run with a nonzero status and a Scheme-readable error
record on the diagnostic stream. An explicit (exit OBJ) follows the standard
R7RS close-status rule: (exit), (exit #t), and (exit 0) close successfully;
any other object closes with an error status.
Arguments after the script reach the process in both forms — the kernel appends
them after the script path for a direct shebang, and the polyglot's "$@"
forwards them through exec. Consent Scheme owns the script-facing
(command-line) value instead of exposing the host process vector, so the
contract is identical across hosts and invocation forms:
;; consent --script tools/example.scm alpha beta
;; consent tools/example.scm alpha beta
(import (scheme base) (scheme process-context))
(command-line)
;; => ("tools/example.scm" "alpha" "beta")The interpreter name and any --script token are removed. The first element is
the script path exactly as supplied to the script runner, followed by user
arguments. This value is invocation metadata, like stdio: it does not grant broad
process-environment access. Outside the script runner, (command-line) remains
policy-gated and fails closed without an explicit process-environment grant.
A shebang script runs noninteractively. Where a script reaches the Consent
capability surface — the (consent …) and (agent …) runtime libraries — it
inherits the batch policy posture of the
native CLI and daemon adapter contract and
fails closed: a confirm-gated action is denied unless it is covered by an
explicit grant, a command-line policy file, or a preloaded approval, and the
denial is recorded as a Scheme-readable audit record rather than raised as a
prompt. With no confirmation channel attached, anything that would prompt is
denied.
Both host script paths evaluate the file through the Consent interpreter
(consent-eval-source), so the posture applies fully and identically — the
host-compiled binary is not a host R7RS interpreter.
The discriminator is standard streams vs ambient authority. The three standard streams are consented by invocation — they are what the shell handed the process — so the CLI attaches them by default; everything else fails closed.
- Standard streams work by default.
consent FILE/--script/--evalreads its stdin and writes stdout/stderr, so an ordinary#!/usr/bin/env consentfilter ((read-line)in a loop,(display …)per line) just works in a pipe. Input is pulled on demand and output is flushed through immediately, so a filter streams a live or unbounded pipe incrementally rather than draining it up front. This is the program stream model in the cross-host REPL interaction contract: the host supplies the stream devices and oneportgrant per stream; the runtime connectscurrent-input-port/current-output-port/current-error-portfrom them and audits every read/write. No raw host objects are exposed to script values. - Ambient effects still fail closed. A confirm-gated capability — opening a
named file, spawning a process, network, environment, clock, a provider — is
denied in batch without a grant, policy file, or preloaded approval. An
(open-output-file …)is denied and audited; the denial raises and the process exits non-zero. A script can shuttle data between its caller-provided stdin and stdout, but it cannot reach any ambient resource without an explicit grant. - Emacs batch runner (
consent-script-run-file). The same contract through the sameconsent-eval-source; a caller passes script arguments separately from evaluator options, and a caller that attaches stdio devices and grants gets the identical behavior.
White-box tests that import the runtime's internal libraries (for example
(consent interpreter)) are not scripts and do not run through this path:
they exercise the compiled libraries on a separate, non-shipped host-execution
test runner, never through consent --script. Host execution is not on the
product command surface.
Batch scripts do not receive ambient model/provider authority. A script that
drives (prompt …) must pass a harness with an explicit prompt-authority
bundle. The bundle records the origin (noninteractive) and how authority was
preloaded (grant, preloaded-approval, or policy-file) so the prompt audit
distinguishes script-driven work from an interactive session.
(import (scheme base) (agent prompt))
(define authority
(make-prompt-authority
'((origin noninteractive)
(source grant)
(grants ((capability-grant
(id script-prompt)
(domain provider)
(operations complete)
(expires never)))))))
(define harness
(make-prompt-harness
(list (list 'authority authority)
(list 'max-steps 4))))
(prompt harness 'summarize
'((provider ((finish done)))
(verifier passed)))A noninteractive bundle with no preloaded authority fails closed; it does not ask the user at runtime:
(import (scheme base) (agent prompt))
(define harness
(make-prompt-harness
(list (list 'authority
(make-prompt-authority
'((origin noninteractive)))))))
(define result (prompt harness 'summarize))
(prompt-result-status result)
;; => authority-missing
(cadr (assq 'reason (cdr (prompt-result-receipt result))))
;; => noninteractive-authority-unavailableThe denial audit is an authority-denied prompt audit carrying
(origin noninteractive) and (source none). Budgets supplied on the harness or
per prompt call continue to flow to the task runner, so a trusted script still
runs under explicit resource limits. Requesting a stdio grant at runtime (rather
than by invocation) remains separate future work; connecting the standard streams
by invocation is in place here.
The shebang-handling boundary is covered at host parity by
tests/scheme/consent-script-test.scm (every portable R7RS host) and
tests/consent-script-test.el (the Emacs host), both of which assert the narrow
recognition rule, the line-preserving strip, and that #!fold-case still reads
normally. The host-compiled build additionally runs end-to-end smokes that make
a real file executable and run the bare-path and /bin/sh-polyglot forms against
the compiled binary, and that assert the Consent-not-host discriminator: a pure
script evaluates and exits 0, while an ungranted file write through --script, a
bare-path script, or --eval is denied and leaves no file. Run the default suite
with:
make test