Skip to content

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 running dexr-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 of dexr-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 get writes the payload to a real output file (the default, -o FILE, or the GUI output directory), DEXR_HOOK_PAYLOAD_PATH points 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 substitute DEXR_HOOK_PAYLOAD_PATH into 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 get semantics 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)