Hook Author Reference
This document is for developers writing a post-get hook — an external command that
DEXR Outpost runs after every successful get. It covers the full calling convention:
which environment variables DEXR sets, how the payload reaches your hook, the failure
model, retry behaviour, quoting rules, and runnable cookbook examples.
Writing Your First Hook
A hook is any executable command. DEXR runs it once after a fully successful get
(payload retrieved, decrypted, and written). Hook stdout and stderr are captured for
logging only; they never replace or modify the downloaded payload.
Wire a hook with --hook:
dexr-outpost get 12345678 --hook 'sha256sum -'
Or persist one in your config so it runs on every get:
dexr-outpost config set DEXR_GET_HOOK 'sha256sum -'
When the hook fires: only after a fully successful get. If the network call,
decryption, or file-write fails, the hook does not run.
What the hook can do: read the payload from stdin or from the path in
DEXR_HOOK_PAYLOAD_PATH, perform side effects (upload, notify, index), and exit.
It cannot change what get outputs — DEXR ignores hook stdout entirely.
Environment Variables
DEXR uses two distinct namespaces. Understanding the direction of data flow prevents confusion:
DEXR_GET_HOOK*— DEXR reads these. Configuration knobs you set in the config file or shell env before runningdexr-outpost get. They control hook behaviour.DEXR_HOOK_*— DEXR writes these into the child env. Contract variables the engine injects for the hook to read. Your hook should not set these.
DEXR_HOOK_* Contract Variables (child-facing)
DEXR injects these into the hook's environment on every invocation.
Always present
| Variable | Type / Format | Description |
|---|---|---|
DEXR_HOOK_SAMPLE_ID |
string | Sample identifier passed to get |
DEXR_HOOK_BYTE_COUNT |
integer as string (e.g. "4096") |
Plaintext payload byte count |
DEXR_HOOK_TRANSFER_UUID |
UUID4 string (e.g. "3f2504e0-...") |
Unique per get call; identical across all retry attempts — use as dedup key |
DEXR_HOOK_PAYLOAD_PATH |
absolute file path | Path to the payload file (0600 permissions) |
DEXR_HOOK_CONTRACT_VERSION |
integer as string | Always "1" in this release |
Conditionally present (only when non-empty)
| Variable | Present when |
|---|---|
DEXR_HOOK_STAGE_ID |
stage_id argument to get is a non-empty string |
DEXR_HOOK_WORKFLOW_ID |
workflow_id argument to get is a non-empty string |
Contract version: DEXR_HOOK_CONTRACT_VERSION is always the string "1" (not "v1" or
"1.0"). If DEXR ever makes breaking changes to the calling convention, this value will
increment. You can gate version-specific behaviour on it.
DEXR_GET_HOOK* Configuration Knobs (DEXR reads)
These control how DEXR invokes and manages the hook. Set them in your config file or in the calling environment.
| Variable | Default | Purpose |
|---|---|---|
DEXR_GET_HOOK |
(empty — no hook) | Hook command string. Empty or unset means no hook runs. |
DEXR_GET_HOOK_TIMEOUT |
20 (seconds) |
Per-attempt wall-clock timeout. Invalid values silently fall back to the default. |
DEXR_GET_HOOK_DEADLINE |
60 (seconds) |
Hard total wall-clock limit across all retry attempts. This is the single latency tuning point — lower it for tight CI step budgets. Invalid values fall back to the default. |
DEXR_GET_HOOK_RETRY |
true |
Boolean string. Set to false (or use --no-retry) to cap at exactly one attempt. |
These four variables are stripped from the child environment — your hook process does not receive them. The separation prevents a hook from reading its own invocation command or launching further hooks.
Child Environment Model
Your hook inherits the full ambient environment (os.environ at the time of the
get call), with two sets subtracted, and the DEXR_HOOK_* contract variables
injected on top:
child env = os.environ
− DEXR secret keys (DEXR_WRITER_KEY, DEXR_READER_KEY, DEXR_PRIVATE_KEY)
− DEXR control vars (DEXR_GET_HOOK, DEXR_GET_HOOK_RETRY,
DEXR_GET_HOOK_TIMEOUT, DEXR_GET_HOOK_DEADLINE)
+ DEXR_HOOK_* contract variables
There is no per-OS allowlist. Operating-system variables (SystemRoot, ComSpec,
PATHEXT on Windows; HOME, TMPDIR, LANG on POSIX) pass through automatically
because they are already in the ambient environment.
Note: DEXR strips only its own secret keys (
DEXR_WRITER_KEY,DEXR_READER_KEY,DEXR_PRIVATE_KEY) and its own control vars. Any other secret present in your shell at the time ofdexr-outpost get—AWS_SECRET_ACCESS_KEY,GITHUB_TOKEN, database passwords, and so on — is visible to the hook exactly as it would be to any subprocess you launch. Scope your shell environment accordingly before running hooks in sensitive contexts.
Payload Delivery
The hook receives the payload through two channels simultaneously. Both are always available; use whichever suits your tool:
stdin: The payload bytes are piped directly to the hook's standard input. On retry, the same in-memory bytes are re-piped — stdin is never a consumed stream. A hook that reads only the first N bytes and exits 0 is treated as success.
DEXR_HOOK_PAYLOAD_PATH: An absolute path to a 0600 file containing the same bytes.
Use this when your tool requires a filename argument rather than a pipe.
Note: When
getwrites the payload to a real output file (the default,-o FILE, or the GUI output directory),DEXR_HOOK_PAYLOAD_PATHpoints to that file — DEXR never deletes it. When the payload goes to stdout only (-o -), DEXR creates an engine-managed temp file (dexr_hook_<random>.bin, 0600) that exists for the duration of the entire hook sequence (all attempts), then deletes it. DEXR does not substituteDEXR_HOOK_PAYLOAD_PATHinto the command string; the hook reads the path from its environment.
Working directory: The hook's working directory is the directory containing the payload file (same across CLI and GUI).
Failure Model, Kill Protocol, and Retry
Fail-open
Hook failures are data, not exceptions. dexr-outpost get always returns the
downloaded payload regardless of whether the hook succeeds. The outcome is available
in GetResult.hook for callers using the library directly, and surfaces as exit code
8 (HOOK_FAILURE) in the CLI. The file is already saved before the hook runs.
Kill protocol
When a hook times out, DEXR terminates the entire process tree:
POSIX (Linux / macOS):
1. SIGTERM sent to the hook's process group.
2. 2.0 second grace window — the hook may catch SIGTERM to clean up.
3. SIGKILL sent to the process group (force-terminates any survivors).
The 2.0 second SIGTERM grace window is a locked contract value. Hook authors may write
SIGTERM handlers that rely on exactly this window.
Windows:
taskkill /F /T terminates the hook and all its child processes. There is no SIGTERM
grace period on Windows.
Retry and backoff
After a retryable failure (non-zero exit, timeout, or transient spawn error), DEXR waits and retries with exponential backoff:
| Attempt | Backoff before next attempt |
|---|---|
| 1 | 1 second |
| 2 | 2 seconds |
| 3+ | 4 seconds |
The per-attempt timeout is clamped to the remaining deadline
(min(DEXR_GET_HOOK_TIMEOUT, deadline − elapsed)). With the defaults (timeout 20 s,
deadline 60 s), the emergent attempt count is approximately 3. There is no retry-count
knob — the attempt count falls out of the deadline arithmetic.
FileNotFoundError (command not found) is not retried. DEXR logs the error and
proceeds hookless immediately.
--no-retry / DEXR_GET_HOOK_RETRY=false caps the hook at exactly one attempt.
After that attempt (regardless of outcome), DEXR fails open and continues.
Idempotency requirement
Because the retry model is at-least-once, your hook may run more than once for the
same get call. Hooks must be idempotent. Use DEXR_HOOK_TRANSFER_UUID as your
dedup key: it is stable across all retry attempts for a single get call.
DEXR_GET_HOOK_DEADLINE is the single latency tuning point. If your CI step has a
tight budget, lower the deadline (e.g. DEXR_GET_HOOK_DEADLINE=10). The timeout
controls how long a single attempt runs; the deadline controls the total wall-clock
cost of the hook sequence.
Quoting Convention
DEXR parses the hook command with shlex.split(posix=True) on all platforms,
including Windows. The command string is split into argv — no shell is involved, no
variable expansion is performed by DEXR.
No variable expansion. $VAR, %VAR%, and $env:VAR are passed as literal
tokens to the child process. To expand variables or use pipes and redirection, route
through a shell explicitly (see cookbook below).
Windows backslash caveat. shlex in POSIX mode treats \ as an escape character.
C:\Tools\hook.exe is misparsed as C:Toolshook.exe. Two safe forms:
# Safe: forward slashes (preferred)
C:/Tools/hook.exe --arg
# Safe: escaped backslashes inside a quoted string
"C:\\Tools\\hook.exe" --arg
Explicit shell opt-in. Prefix the command with sh -c, cmd /c, or powershell
when you need pipes, redirection, or variable expansion:
sh -c "jq .output \"$DEXR_HOOK_PAYLOAD_PATH\" > /tmp/result.json"
Boundary validation. If a persisted hook command cannot be parsed (e.g. unbalanced
quotes), DEXR disables it with a warning and proceeds hookless. The get completes
normally; the hook is simply skipped.
CLI Surface
The hook-related CLI flags and exit codes are documented in detail in docs/cli-reference.md. A brief summary of the hook-relevant items:
| Item | Behaviour |
|---|---|
--hook CMD |
Replaces the configured hook for this invocation only |
--no-retry |
Caps the hook at one attempt |
--force |
Bypasses the overwrite guard on the output file |
-o - |
Streams payload to stdout; DEXR_HOOK_PAYLOAD_PATH uses a temp file |
-v / --verbose |
On hook failure: echoes captured hook stdout/stderr to stderr |
-q / --quiet |
Suppresses progress spinner and hook attempt lines |
Exit code 8 (HOOK_FAILURE) |
Retrieval succeeded and file is saved; the hook failed |
Exit code 130 |
User pressed Ctrl-C — distinct from hook failure |
For the full exit-code table see cli-reference.md#exit-codes.
For the full --hook / --no-retry flag documentation see
cli-reference.md#hooks.
CLI vs GUI behaviour
CLI: when the hook fails, dexr-outpost get exits with code 8. The file is already
saved. Scripts can test $? to detect hook failure independently of download success.
GUI: hook failure is non-modal. The download tile shows "Saved to …" (success) and a separate inline hook-status indicator (failed / timed-out / cancelled). A failed hook never causes the download tile to appear failed. Both surfaces read the same engine result — neither parses logs.
Re-entrancy Warning
Note: Hooks must not call
dexr-outpost get(directly or via a script that does). Recursive hook invocations are not detected or blocked in this release. A re-entrant hook will run the outer hook again, which can loop until the deadline expires.
Deferred Capabilities
Payload-rewriting / veto hooks — where a hook's stdout replaces the downloaded payload — are intentionally out of scope in this release. Hook stdout is captured for logging only and never fed back as the payload, so
getsemantics stay predictable. A depth or re-entrancy guard (to enforce the warning above at runtime) is likewise not provided in this release.
Cookbook Examples
Example 1: stdin filter
The simplest hook reads from stdin and exits. No special setup needed.
# Compute a checksum of every payload received
dexr-outpost get 12345678 --hook 'sha256sum -'
# Extract a field from a JSON payload
dexr-outpost get 12345678 --hook 'jq .output'
Example 2: Idempotent uploader
When a hook may run more than once (retry), key your side-effect on
DEXR_HOOK_TRANSFER_UUID to avoid double-uploads.
#!/usr/bin/env bash
# upload-hook.sh — idempotent S3 upload keyed on transfer UUID
set -euo pipefail
KEY="uploaded:${DEXR_HOOK_TRANSFER_UUID}"
# Skip if already uploaded (exact-match the dedup store — avoid substring false positives)
if [ "$(redis-cli GET "$KEY")" = "1" ]; then
echo "already uploaded, skipping" >&2
exit 0
fi
# Stream payload straight from stdin to S3 — no shell buffering, preserves binary bytes
aws s3 cp - "s3://my-bucket/${DEXR_HOOK_SAMPLE_ID}.bin"
# Mark as uploaded with a 24-hour TTL
redis-cli SET "$KEY" "1" EX 86400
Wire it up:
dexr-outpost config set DEXR_GET_HOOK '/path/to/upload-hook.sh'
Example 3: By-path consumer (DEXR_HOOK_PAYLOAD_PATH)
When your tool requires a filename argument rather than a pipe, read the path from
the environment. Use explicit shell opt-in to expand $DEXR_HOOK_PAYLOAD_PATH:
# Run an antivirus scanner on the payload file
dexr-outpost get 12345678 --hook 'sh -c "clamscan \"$DEXR_HOOK_PAYLOAD_PATH\""'
# Index the payload by path
dexr-outpost get 12345678 --hook 'sh -c "my-indexer --file \"$DEXR_HOOK_PAYLOAD_PATH\" --id \"$DEXR_HOOK_SAMPLE_ID\""'
Example 4: Windows quoting
Use forward slashes (preferred) or escaped backslashes in a quoted string.
shlex POSIX mode runs on Windows too — standard Windows paths need adjustment.
# Forward slashes — preferred
--hook 'C:/Tools/MyHook.exe --arg'
# Escaped backslashes in a double-quoted string
--hook '"C:\\Tools\\MyHook.exe" --arg'
Example 5: Explicit shell opt-in (pipes, redirection, expansion)
DEXR expands nothing. Route through a shell when you need shell features.
# POSIX: redirect hook output to a file
dexr-outpost get 12345678 \
--hook 'sh -c "jq .output \"$DEXR_HOOK_PAYLOAD_PATH\" > /tmp/result.json"'
# Windows: equivalent with cmd
--hook "cmd /c \"jq .output \"%DEXR_HOOK_PAYLOAD_PATH%\" > C:/tmp/result.json\""
Example 6: SIGTERM-tolerant hook
On POSIX, DEXR sends SIGTERM and waits 2.0 seconds before SIGKILL. Install a handler to clean up partial state within that window.
#!/usr/bin/env python3
"""sigterm-tolerant-hook.py — cleans up on timeout."""
import os
import pathlib
import signal
import sys
TRANSFER_UUID = os.environ["DEXR_HOOK_TRANSFER_UUID"]
LOCK = pathlib.Path(f"/tmp/myhook_{TRANSFER_UUID}.lock")
def _cleanup(signum, frame):
LOCK.unlink(missing_ok=True)
sys.exit(1)
signal.signal(signal.SIGTERM, _cleanup)
LOCK.touch()
payload = sys.stdin.buffer.read()
# ... process payload ...
LOCK.unlink(missing_ok=True)