Skip to content

Commit 0419a74

Browse files
authored
Support generic fan-out env projection (#489)
* fix: route CLI channel auth through WP AI Gateway * fix: keep fan-out env projection generic
1 parent f3aa824 commit 0419a74

6 files changed

Lines changed: 212 additions & 39 deletions

inc/Channels/CliChannelRegistry.php

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
* detach?: bool,
3232
* timeout?: int,
3333
* env?: array<string, string>,
34+
* env_from?: array<string, string>,
3435
* cwd?: string|null,
3536
* }
3637
*/
@@ -188,18 +189,35 @@ public static function normalize_entry( array $config ): ?array {
188189
$normalized_env[ $env_key ] = (string) $env_value;
189190
}
190191

192+
$env_from = $config['env_from'] ?? array();
193+
if ( ! is_array($env_from) ) {
194+
$env_from = array();
195+
}
196+
$normalized_env_from = array();
197+
foreach ( $env_from as $env_key => $source_env_key ) {
198+
if ( ! is_string($env_key) || '' === $env_key || ! is_scalar($source_env_key) ) {
199+
continue;
200+
}
201+
202+
$source_env_key = trim( (string) $source_env_key );
203+
if ( '' !== $source_env_key ) {
204+
$normalized_env_from[ $env_key ] = $source_env_key;
205+
}
206+
}
207+
191208
$cwd = $config['cwd'] ?? null;
192209
if ( null !== $cwd && ( ! is_string($cwd) || '' === $cwd ) ) {
193210
$cwd = null;
194211
}
195212

196213
return array(
197-
'command' => $command,
198-
'args' => $normalized_args,
199-
'detach' => $detach,
200-
'timeout' => $timeout,
201-
'env' => $normalized_env,
202-
'cwd' => $cwd,
214+
'command' => $command,
215+
'args' => $normalized_args,
216+
'detach' => $detach,
217+
'timeout' => $timeout,
218+
'env' => $normalized_env,
219+
'env_from' => $normalized_env_from,
220+
'cwd' => $cwd,
203221
);
204222
}
205223

inc/Channels/CliChannelTransport.php

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
namespace DataMachineCode\Channels;
3030

3131
use DataMachineCode\Environment;
32+
use DataMachineCode\Support\SecretRedactor;
3233
use WP_Error;
3334

3435
defined('ABSPATH') || exit;
@@ -163,13 +164,16 @@ public static function execute( array $input ) {
163164
$detach = (bool) ( $config['detach'] ?? true );
164165
$timeout = isset($config['timeout']) && is_int($config['timeout']) ? $config['timeout'] : self::DEFAULT_TIMEOUT_SECONDS;
165166
$cwd = isset($config['cwd']) && is_string($config['cwd']) && '' !== $config['cwd'] ? $config['cwd'] : null;
166-
$env = self::build_env_map(isset($config['env']) && is_array($config['env']) ? $config['env'] : array());
167+
$env = self::build_env_map(
168+
isset($config['env']) && is_array($config['env']) ? $config['env'] : array(),
169+
isset($config['env_from']) && is_array($config['env_from']) ? $config['env_from'] : array()
170+
);
167171

168172
if ( $detach ) {
169-
return self::dispatch_detached($channel, $recipient, $command_args, $cwd, $env);
173+
return self::dispatch_detached($channel, $recipient, $command_args, $cwd, $env['values']);
170174
}
171175

172-
return self::dispatch_sync($channel, $recipient, $command_args, $cwd, $env, $timeout);
176+
return self::dispatch_sync($channel, $recipient, $command_args, $cwd, $env['values'], $env['secrets'], $timeout);
173177
}
174178

175179
/**
@@ -232,7 +236,7 @@ private static function dispatch_detached( string $channel, string $recipient, a
232236
* @param int $timeout Timeout in seconds.
233237
* @return array<string, mixed>|WP_Error
234238
*/
235-
private static function dispatch_sync( string $channel, string $recipient, array $argv, ?string $cwd, ?array $env, int $timeout ) {
239+
private static function dispatch_sync( string $channel, string $recipient, array $argv, ?string $cwd, ?array $env, array $secrets, int $timeout ) {
236240
$descriptors = array(
237241
0 => array( 'pipe', 'r' ),
238242
1 => array( 'pipe', 'w' ),
@@ -328,8 +332,8 @@ private static function dispatch_sync( string $channel, string $recipient, array
328332
array(
329333
'channel' => $channel,
330334
'recipient' => $recipient,
331-
'stdout' => self::truncate_output($stdout),
332-
'stderr' => self::truncate_output($stderr),
335+
'stdout' => self::truncate_output($stdout, $secrets),
336+
'stderr' => self::truncate_output($stderr, $secrets),
333337
'duration_ms' => $duration_ms,
334338
)
335339
);
@@ -343,8 +347,8 @@ private static function dispatch_sync( string $channel, string $recipient, array
343347
'channel' => $channel,
344348
'recipient' => $recipient,
345349
'exit_code' => $exit_code,
346-
'stdout' => self::truncate_output($stdout),
347-
'stderr' => self::truncate_output($stderr),
350+
'stdout' => self::truncate_output($stdout, $secrets),
351+
'stderr' => self::truncate_output($stderr, $secrets),
348352
'duration_ms' => $duration_ms,
349353
)
350354
);
@@ -359,8 +363,8 @@ private static function dispatch_sync( string $channel, string $recipient, array
359363
'mode' => 'sync',
360364
'exit_code' => $exit_code,
361365
'duration_ms' => $duration_ms,
362-
'stdout' => self::truncate_output($stdout),
363-
'stderr' => self::truncate_output($stderr),
366+
'stdout' => self::truncate_output($stdout, $secrets),
367+
'stderr' => self::truncate_output($stderr, $secrets),
364368
),
365369
);
366370
}
@@ -415,10 +419,12 @@ private static function open_process( array $argv, array $descriptors, ?string $
415419
* the inherited PATH if it provides one.
416420
*
417421
* @param array<string, string> $configured Configured env map.
418-
* @return array<string, string>
422+
* @param array<string, string> $env_from Child env name => parent env name.
423+
* @return array{values:array<string,string>,secrets:string[]}
419424
*/
420-
private static function build_env_map( array $configured ): array {
421-
$env = array();
425+
private static function build_env_map( array $configured, array $env_from ): array {
426+
$env = array();
427+
$secrets = array();
422428

423429
$parent_path = getenv('PATH');
424430
if ( is_string($parent_path) && '' !== $parent_path ) {
@@ -427,9 +433,42 @@ private static function build_env_map( array $configured ): array {
427433

428434
foreach ( $configured as $key => $value ) {
429435
$env[ $key ] = $value;
436+
if ( self::is_secret_like_env_key( (string) $key ) ) {
437+
$secrets[] = $value;
438+
}
430439
}
431440

432-
return $env;
441+
foreach ( $env_from as $target_key => $source_key ) {
442+
$value = self::parent_env( (string) $source_key );
443+
if ( '' === $value ) {
444+
continue;
445+
}
446+
447+
$env[ $target_key ] = $value;
448+
if ( self::is_secret_like_env_key( (string) $target_key ) || self::is_secret_like_env_key( (string) $source_key ) ) {
449+
$secrets[] = $value;
450+
}
451+
}
452+
453+
return array(
454+
'values' => $env,
455+
'secrets' => array_values(array_unique(array_filter($secrets, static fn( string $secret ): bool => strlen(trim($secret)) >= 8))),
456+
);
457+
}
458+
459+
/**
460+
* Read a trimmed parent environment variable.
461+
*/
462+
private static function parent_env( string $name ): string {
463+
$value = getenv($name);
464+
return is_string($value) ? trim($value) : '';
465+
}
466+
467+
/**
468+
* Determine whether an environment key conventionally carries a secret.
469+
*/
470+
private static function is_secret_like_env_key( string $key ): bool {
471+
return 1 === preg_match('/(?:^|_)(TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|ACCESS_KEY|AUTH|COOKIE|NONCE)(?:_|$)/i', $key);
433472
}
434473

435474
/**
@@ -438,8 +477,9 @@ private static function build_env_map( array $configured ): array {
438477
* @param string $output Captured output.
439478
* @return string Truncated output.
440479
*/
441-
private static function truncate_output( string $output ): string {
442-
$limit = 8192;
480+
private static function truncate_output( string $output, array $secrets = array() ): string {
481+
$output = SecretRedactor::redact($output, $secrets);
482+
$limit = 8192;
443483
if ( strlen($output) <= $limit ) {
444484
return $output;
445485
}

inc/Support/RunArtifactPrSectionRenderer.php

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ private static function sanitizeValue( mixed $value ): mixed {
344344
}
345345

346346
if ( is_string($value) ) {
347-
return self::redactSecretLikeString($value);
347+
return SecretRedactor::redact($value);
348348
}
349349

350350
return $value;
@@ -354,14 +354,6 @@ private static function isSecretLikeKey( string $key ): bool {
354354
return 1 === preg_match('/(authorization|credential|secret|token|password|passwd|private[_-]?key|api[_-]?key|github[_-]?pat|bearer)/i', $key);
355355
}
356356

357-
private static function redactSecretLikeString( string $value ): string {
358-
$value = preg_replace('/\b(gh[pousr]_[A-Za-z0-9_]{12,})\b/', '[redacted]', $value) ?? $value;
359-
$value = preg_replace('/\b(sk-[A-Za-z0-9_-]{12,})\b/', '[redacted]', $value) ?? $value;
360-
$value = preg_replace('/\b(xox[baprs]-[A-Za-z0-9-]{12,})\b/', '[redacted]', $value) ?? $value;
361-
362-
return preg_replace('/\b(authorization|token|password|secret|api[_-]?key)\s*[:=]\s*\S+/i', '$1: [redacted]', $value) ?? $value;
363-
}
364-
365357
/**
366358
* @param array<int|string, mixed> $value Source array.
367359
* @param array<int, string> $keys Candidate keys.

inc/Support/SecretRedactor.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
/**
3+
* Shared secret redaction helpers.
4+
*
5+
* @package DataMachineCode\Support
6+
*/
7+
8+
namespace DataMachineCode\Support;
9+
10+
defined('ABSPATH') || exit;
11+
12+
class SecretRedactor {
13+
14+
/**
15+
* Redact secret-looking values from arbitrary text.
16+
*/
17+
public static function redact( string $value, array $secrets = array() ): string {
18+
foreach ( self::normalize_secrets($secrets) as $secret ) {
19+
$value = str_replace($secret, '[redacted]', $value);
20+
}
21+
22+
$value = preg_replace('/\b(gh[pousr]_[A-Za-z0-9_]{12,})\b/', '[redacted]', $value) ?? $value;
23+
$value = preg_replace('/\b(sk-[A-Za-z0-9_-]{12,})\b/', '[redacted]', $value) ?? $value;
24+
$value = preg_replace('/\b(xox[baprs]-[A-Za-z0-9-]{12,})\b/', '[redacted]', $value) ?? $value;
25+
$value = preg_replace('/\b(Bearer)\s+[A-Za-z0-9._~+\/-]{8,}/i', '$1 [redacted]', $value) ?? $value;
26+
27+
return preg_replace('/\b(authorization|token|password|secret|api[_-]?key)\s*[:=]\s*\S+/i', '$1: [redacted]', $value) ?? $value;
28+
}
29+
30+
/**
31+
* Normalize explicit secret values that must be redacted.
32+
*
33+
* @param array<int,string> $secrets Secret values.
34+
* @return string[]
35+
*/
36+
private static function normalize_secrets( array $secrets ): array {
37+
$values = array();
38+
foreach ( $secrets as $value ) {
39+
if ( strlen(trim($value)) >= 8 ) {
40+
$values[] = trim($value);
41+
}
42+
}
43+
44+
return array_values(array_unique($values));
45+
}
46+
}

tests/smoke-cli-channel-transport.php

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ function get_option( string $key, mixed $default_value = false ): mixed
9595
}
9696

9797
include __DIR__ . '/../inc/Environment.php';
98+
include __DIR__ . '/../inc/Support/SecretRedactor.php';
9899
include __DIR__ . '/../inc/Channels/CliChannelRegistry.php';
99100
include __DIR__ . '/../inc/Channels/CliChannelTransport.php';
100101

@@ -112,13 +113,22 @@ function get_option( string $key, mixed $default_value = false ): mixed
112113

113114
// Resolve standard stub binaries. Bail with a clear diagnostic if
114115
// the host is missing them — the runtime needs real subprocess capability.
115-
$echo_bin = '/bin/echo';
116-
$true_bin = '/bin/true';
117-
$false_bin = '/bin/false';
118-
$sleep_bin = '/bin/sleep';
119-
foreach ( array( $echo_bin, $true_bin, $false_bin, $sleep_bin ) as $candidate ) {
120-
if (! is_executable($candidate) ) {
121-
echo " [SKIP] stub binary {$candidate} not present; smoke cannot run on this host\n";
116+
$resolve_bin = static function ( array $candidates ): ?string {
117+
foreach ( $candidates as $candidate ) {
118+
if (is_executable($candidate) ) {
119+
return $candidate;
120+
}
121+
}
122+
return null;
123+
};
124+
$echo_bin = $resolve_bin(array( '/bin/echo', '/usr/bin/echo' ));
125+
$true_bin = $resolve_bin(array( '/bin/true', '/usr/bin/true' ));
126+
$false_bin = $resolve_bin(array( '/bin/false', '/usr/bin/false' ));
127+
$sleep_bin = $resolve_bin(array( '/bin/sleep', '/usr/bin/sleep' ));
128+
$php_bin = PHP_BINARY;
129+
foreach ( array( $echo_bin, $true_bin, $false_bin, $sleep_bin, $php_bin ) as $candidate ) {
130+
if (! is_string($candidate) || ! is_executable($candidate) ) {
131+
echo " [SKIP] required stub binary not present; smoke cannot run on this host\n";
122132
exit(0);
123133
}
124134
}
@@ -136,12 +146,14 @@ function get_option( string $key, mixed $default_value = false ): mixed
136146
'args' => array( '--', '{recipient}', '{message}' ),
137147
'detach' => false,
138148
'timeout' => 5,
149+
'env_from' => array( 'CHILD_SECRET_TOKEN' => 'PARENT_SECRET_TOKEN' ),
139150
);
140151
$normalized = \DataMachineCode\Channels\CliChannelRegistry::normalize_entry($valid_entry);
141152
$assert('valid entry normalizes', is_array($normalized) && $normalized['command'] === $echo_bin);
142153
$assert('normalized entry has args array', is_array($normalized['args'] ?? null) && count($normalized['args']) === 3);
143154
$assert('normalized entry preserves detach false', false === ( $normalized['detach'] ?? null ));
144155
$assert('normalized entry preserves timeout', 5 === ( $normalized['timeout'] ?? null ));
156+
$assert('normalized entry preserves generic env_from references', array( 'CHILD_SECRET_TOKEN' => 'PARENT_SECRET_TOKEN' ) === ( $normalized['env_from'] ?? null ));
145157

146158
$bad_no_command = \DataMachineCode\Channels\CliChannelRegistry::normalize_entry(array( 'args' => array() ));
147159
$assert('missing command is rejected', null === $bad_no_command);
@@ -380,6 +392,69 @@ function get_option( string $key, mixed $default_value = false ): mixed
380392
$assert('detached metadata mode=detached', 'detached' === ( $metadata['mode'] ?? null ));
381393
}
382394

395+
// ---------------------------------------------------------------
396+
// Generic env projection: caller-provided parent env references are
397+
// projected into child env and secret-like values are redacted.
398+
// ---------------------------------------------------------------
399+
400+
putenv('DMC_TEST_PARENT_BASE_URL=https://runtime.example/v1');
401+
putenv('DMC_TEST_PARENT_SECRET_TOKEN=caller-secret-token-1234567890');
402+
$datamachine_code_test_options['datamachine_code_cli_channels'] = array(
403+
'projected-env' => array(
404+
'command' => $php_bin,
405+
'args' => array(
406+
'-r',
407+
'$ok = getenv("CHILD_BASE_URL") === "https://runtime.example/v1" && getenv("CHILD_API_TOKEN") === "caller-secret-token-1234567890"; echo "CHILD_BASE_URL=" . getenv("CHILD_BASE_URL") . "\n"; echo "CHILD_API_TOKEN=" . getenv("CHILD_API_TOKEN") . "\n"; exit($ok ? 0 : 7);',
408+
),
409+
'detach' => false,
410+
'timeout' => 5,
411+
'env' => array(
412+
'STATIC_ENV' => 'static-value',
413+
),
414+
'env_from' => array(
415+
'CHILD_BASE_URL' => 'DMC_TEST_PARENT_BASE_URL',
416+
'CHILD_API_TOKEN' => 'DMC_TEST_PARENT_SECRET_TOKEN',
417+
),
418+
),
419+
'static-env' => array(
420+
'command' => $php_bin,
421+
'args' => array(
422+
'-r',
423+
'exit(getenv("STATIC_SECRET_TOKEN") === "static-secret-value" ? 0 : 9);',
424+
),
425+
'detach' => false,
426+
'timeout' => 5,
427+
'env' => array(
428+
'STATIC_SECRET_TOKEN' => 'static-secret-value',
429+
),
430+
),
431+
);
432+
433+
$projected = \DataMachineCode\Channels\CliChannelTransport::execute(
434+
array(
435+
'channel' => 'projected-env',
436+
'recipient' => 'r',
437+
'message' => 'm',
438+
)
439+
);
440+
$assert('projected env dispatch succeeds', is_array($projected) && true === ( $projected['sent'] ?? false ));
441+
if (is_array($projected) ) {
442+
$stdout = (string) ( $projected['metadata']['stdout'] ?? '' );
443+
$assert('caller-provided base URL reference is projected', str_contains($stdout, 'CHILD_BASE_URL=https://runtime.example/v1'));
444+
$assert('projected secret value is redacted from captured stdout', str_contains($stdout, '[redacted]') && ! str_contains($stdout, 'caller-secret-token-1234567890'));
445+
}
446+
447+
putenv('DMC_TEST_PARENT_BASE_URL');
448+
putenv('DMC_TEST_PARENT_SECRET_TOKEN');
449+
$static_env = \DataMachineCode\Channels\CliChannelTransport::execute(
450+
array(
451+
'channel' => 'static-env',
452+
'recipient' => 'r',
453+
'message' => 'm',
454+
)
455+
);
456+
$assert('static configured env continues to work', is_array($static_env) && true === ( $static_env['sent'] ?? false ));
457+
383458
// ---------------------------------------------------------------
384459
// Summary
385460
// ---------------------------------------------------------------

0 commit comments

Comments
 (0)