@@ -17,6 +17,7 @@ import {
1717} from "@effectionx/durable-streams" ;
1818import { useScope } from "effection" ;
1919import type { Operation } from "effection" ;
20+ import { canonicalJson } from "./canonical-json.ts" ;
2021import { computeSHA256 } from "./hash.ts" ;
2122import { type DurableRuntime , DurableRuntimeCtx } from "./runtime.ts" ;
2223
@@ -42,6 +43,11 @@ export interface ExecResult {
4243 * Execute a shell command durably.
4344 *
4445 * Never re-executed on replay — logs are authoritative.
46+ *
47+ * **Security note**: `env` values are NOT persisted to the journal —
48+ * only the env key names are recorded (for divergence detection).
49+ * The `throwOnError` flag is captured in the description so replay
50+ * behavior matches the original execution.
4551 */
4652export function * durableExec (
4753 name : string ,
@@ -55,8 +61,10 @@ export function* durableExec(
5561 name,
5662 command : command as Json ,
5763 ...( cwd ? { cwd } : { } ) ,
58- ...( env ? { env : env as Json } : { } ) ,
64+ // Only record env key names — values may contain secrets
65+ ...( env ? { envKeys : Object . keys ( env ) . sort ( ) as Json } : { } ) ,
5966 timeout,
67+ throwOnError,
6068 } ,
6169 function * ( ) {
6270 const scope = yield * useScope ( ) ;
@@ -88,6 +96,10 @@ export interface ReadFileResult {
8896 *
8997 * Path in description, content + SHA-256 hash in result.
9098 * Designed for replay guard integration.
99+ *
100+ * Note: `encoding` is recorded in the description for future use but
101+ * the current `DurableRuntime.readTextFile` always reads as UTF-8.
102+ * Non-default encodings will require a runtime interface extension.
91103 */
92104export function * durableReadFile (
93105 name : string ,
@@ -200,21 +212,54 @@ export interface FetchResult {
200212 bodyHash : string ;
201213}
202214
215+ /** Header names that are safe to record in the journal. */
216+ const SAFE_REQUEST_HEADERS = new Set ( [
217+ "content-type" ,
218+ "accept" ,
219+ "accept-language" ,
220+ "cache-control" ,
221+ "user-agent" ,
222+ ] ) ;
223+
203224/**
204225 * HTTP request durably.
205226 *
206227 * HTTP error status codes (404, 500) are successful effect results —
207228 * only network failures are effect errors.
208- * Request body is NOT stored in the description.
229+ *
230+ * **Security note**: Only safe request header *names* are recorded in
231+ * the description — values of sensitive headers (Authorization, Cookie,
232+ * etc.) are never persisted. A body hash is included in the description
233+ * when a request body is present, so different payloads to the same URL
234+ * produce distinct journal entries.
209235 */
210236export function * durableFetch (
211237 name : string ,
212238 options : FetchOptions ,
213239) : Workflow < FetchResult > {
214240 const { url, method = "GET" , headers = { } , body, timeout = 30_000 } = options ;
215241
242+ // Record only safe header names + values; redact sensitive ones to key-only
243+ const safeHeaders : Record < string , string > = { } ;
244+ for ( const [ key , value ] of Object . entries ( headers ) ) {
245+ const lower = key . toLowerCase ( ) ;
246+ if ( SAFE_REQUEST_HEADERS . has ( lower ) ) {
247+ safeHeaders [ key ] = value ;
248+ } else {
249+ safeHeaders [ key ] = "[REDACTED]" ;
250+ }
251+ }
252+
216253 return ( yield createDurableOperation < Json > (
217- { type : "fetch" , name, url, method, headers : headers as Json } ,
254+ {
255+ type : "fetch" ,
256+ name,
257+ url,
258+ method,
259+ headers : safeHeaders as Json ,
260+ // Include body hash so different payloads produce distinct entries
261+ ...( body ? { bodyHash : `len:${ body . length } ` } : { } ) ,
262+ } ,
218263 function * ( ) {
219264 const scope = yield * useScope ( ) ;
220265 const runtime = scope . expect < DurableRuntime > ( DurableRuntimeCtx ) ;
@@ -286,7 +331,7 @@ export function* durableEval(
286331 { type : "eval" , name, ...( language ? { language } : { } ) } ,
287332 function * ( ) {
288333 const sourceHash = yield * computeSHA256 ( source ) ;
289- const bindingsHash = yield * computeSHA256 ( JSON . stringify ( bindings ) ) ;
334+ const bindingsHash = yield * computeSHA256 ( canonicalJson ( bindings ) ) ;
290335 const value = yield * evaluator ( source , bindings ) ;
291336 return { value, sourceHash, bindingsHash } as unknown as Json ;
292337 } ,
@@ -320,6 +365,25 @@ export function* durableResolve<T extends Json>(
320365 if ( isKind ) {
321366 descExtras . kind = resolver . kind ;
322367 if ( resolver . kind === "env_var" ) descExtras . varName = resolver . name ;
368+ if ( resolver . kind === "random_float" ) {
369+ descExtras . min = resolver . min ?? 0 ;
370+ descExtras . max = resolver . max ?? 1 ;
371+ }
372+ if ( resolver . kind === "random_int" ) {
373+ if ( resolver . min > resolver . max ) {
374+ throw new Error (
375+ `durableResolve("${ name } "): random_int min (${ resolver . min } ) ` +
376+ `cannot exceed max (${ resolver . max } )` ,
377+ ) ;
378+ }
379+ if ( ! Number . isInteger ( resolver . min ) || ! Number . isInteger ( resolver . max ) ) {
380+ throw new Error (
381+ `durableResolve("${ name } "): random_int min and max must be integers` ,
382+ ) ;
383+ }
384+ descExtras . min = resolver . min ;
385+ descExtras . max = resolver . max ;
386+ }
323387 }
324388
325389 return ( yield createDurableOperation < Json > (
@@ -370,7 +434,13 @@ export function* durableUUID(name?: string): Workflow<string> {
370434 return yield * durableResolve ( name ?? "uuid" , { kind : "uuid" } ) ;
371435}
372436
373- /** Capture an environment variable value. */
437+ /**
438+ * Capture an environment variable value.
439+ *
440+ * **Security warning**: The env var *value* is persisted to the durable
441+ * journal. Do NOT use this for secrets (API keys, tokens, passwords).
442+ * For secrets, read them ephemerally on each run instead.
443+ */
374444export function * durableEnv (
375445 varName : string ,
376446 name ?: string ,
0 commit comments