Skip to content
Merged
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ use `awf --reflect`. It prints the `/reflect` JSON response to stdout.
- **Data protection controls**: DLP scanning (`--enable-dlp`), DNS-over-HTTPS, and agent runtime limits (`--agent-timeout`)
- **API proxy capabilities**: OpenAI, Anthropic, Copilot, Gemini, and Google Vertex AI targets with rate limits, token steering, Anthropic auto-cache, and `apiProxy.hostedWeb.{claude,codex}` domain policies
- **Infrastructure flexibility**: upstream proxy chaining, host service access, Docker-in-Docker, custom mounts, memory limits, and TTY mode
- **Protected output capture**: opt-in, host-only command stdout/stderr capture with attempt and completeness metadata
- **Operational tooling**: pre-download images and inspect logs/stats/summaries/audits from live or saved runs

## CLI subcommands
Expand Down Expand Up @@ -83,6 +84,7 @@ See [GitHub Actions](docs/github_actions.md) for advanced setup and `awf logs su
- [API proxy sidecar](docs/api-proxy-sidecar.md) — secure credential management for LLM APIs
- [Authentication architecture](docs/authentication-architecture.md) — deep dive into token handling and credential isolation
- [Authentication matrix](docs/auth-matrix.md) — supported static, BYOK, and OIDC provider combinations
- [Protected command output capture](docs/protected-command-output.md) — isolated raw stdout/stderr capture and completion metadata
- [SSL Bump](docs/ssl-bump.md) — HTTPS content inspection for URL path filtering
- [GitHub Actions](docs/github_actions.md) — CI/CD integration and MCP server setup
- [Environment variables](docs/environment.md) — passing environment variables to containers
Expand Down
12 changes: 11 additions & 1 deletion containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
set -e

configure_output_routing() {
if [ "${AWF_CAPTURE_COMMAND_OUTPUT:-}" = "1" ]; then
# Preserve the container streams for command bytes and keep AWF setup
# diagnostics out of both captured streams.
exec 3>&1 4>&2
# Keep entrypoint diagnostics visible on the original stderr stream.
exec 1>&4 2>&4
return
fi
if [ "${AWF_COMMAND_STDOUT_ONLY:-}" != "1" ]; then
return
fi
Expand All @@ -12,7 +20,9 @@ exec 1>&2
}

run_command_with_stdout() {
if [ "${AWF_COMMAND_STDOUT_ONLY:-}" = "1" ]; then
if [ "${AWF_CAPTURE_COMMAND_OUTPUT:-}" = "1" ]; then
"$@" >&3 2>&4 3>&- 4>&-
elif [ "${AWF_COMMAND_STDOUT_ONLY:-}" = "1" ]; then
"$@" >&3 3>&-
else
"$@"
Expand Down
38 changes: 38 additions & 0 deletions docs/protected-command-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Protected command output capture

Use `--capture-command-output <directory>` to ask AWF to retain the command's
raw stdout and stderr for a trusted caller:

```sh
awf --capture-command-output "$RUNNER_TEMP/awf-capture-$GITHUB_RUN_ID" \
--allow-domains github.com -- your-command
```

The directory must be an absolute path whose parent exists; the directory itself
must not already exist. AWF resolves symlinks and checks the candidate against agent-visible mounts,
including custom read-only mounts and Docker host-path-prefix translations.
It fails before starting the command if the directory could be exposed. A
caller must not assume that `$RUNNER_TEMP` or any other chosen path is private.

AWF creates `metadata.json` and one `.stdout.bin` and `.stderr.bin` file per
runtime attempt with private permissions. Capture is bounded to 64 MiB total by
default; `--capture-command-output-max-bytes <bytes>` can lower that limit.
Output continues to drain and appear on the usual console streams after the
limit is reached. Metadata reports observed and retained byte counts, truncation,
log-drain status, per-attempt exit/outcome, command outcome, console-presentation
failures, and teardown outcome. An interrupted capture records incomplete draining.
A capture is `complete` only when the command exit was observed, all
logs drained, no capture failure or truncation occurred, and teardown completed.
A nonzero command exit can still have a complete capture.

Stdout and stderr are saved separately. Byte order is preserved within each
stream and attempt; AWF does not claim a total order between the two streams.
Capture currently supports non-TTY Compose-managed runtimes (including Docker
and gVisor). TTY, external microVM runtimes, and configurations that expose a
Docker socket to the agent are rejected explicitly.

AWF leaves the raw files in the requested host directory and does not copy or
publish them. The caller owns parsing, provenance checks, redaction, and
publication. Captured bytes prove only what AWF observed; they do not make
guest-emitted claims authoritative. Treat the files as sensitive and apply the
caller’s retention policy.
25 changes: 25 additions & 0 deletions src/agent-entrypoint-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,29 @@ describe('agent entrypoint output routing', () => {
expect(result.stdout).toContain('[entrypoint] Agentic Workflow Firewall');
expect(result.stdout).toContain('command-output');
});

it('keeps setup diagnostics out of both command capture streams', () => {
const entrypoint = path.join(__dirname, '..', 'containers', 'agent', 'entrypoint.sh');
const result = spawnSync(
'/bin/bash',
[
'-c',
[
'source "$1"',
'export AWF_CAPTURE_COMMAND_OUTPUT=1',
'configure_output_routing',
'print_banner',
'run_command_with_stdout bash -c \'printf stdout-bytes; printf stderr-bytes >&2\'',
].join('\n'),
'entrypoint-output-test',
entrypoint,
],
{ encoding: 'utf8' },
);

expect(result.status).toBe(0);
expect(result.stdout).toBe('stdout-bytes');
expect(result.stderr).toContain('[entrypoint] Agentic Workflow Firewall');
expect(result.stderr).toContain('stderr-bytes');
});
});
8 changes: 8 additions & 0 deletions src/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,14 @@ program
'Allocate a pseudo-TTY (required for interactive tools like Claude Code)',
false
)
.option(
'--capture-command-output <directory>',
'Capture raw stdout/stderr into protected host-only storage (non-TTY Compose runtimes only)',
)
.option(
'--capture-command-output-max-bytes <bytes>',
'Maximum total bytes retained across command output capture (default: 67108864; max: 67108864)',
)

// -- Network & Security --
.option(
Expand Down
20 changes: 18 additions & 2 deletions src/cli-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { HostPreflightReporter } from './cloud-hypervisor/host-preflight-progress';
import { isCloudHypervisorEnclaveSelected } from './enclave/cloud-hypervisor-lifecycle';
import { ensureAgentRuntimeStartMarker } from './agent-runtime-start';
import type { ProtectedCommandOutputCapture } from './protected-command-output-capture';

/**
* Dependencies injected into the main workflow.
Expand All @@ -39,7 +40,8 @@ export interface WorkflowDependencies {
workDir: string,
allowedDomains: string[],
proxyLogsDir?: string,
agentTimeoutMinutes?: number
agentTimeoutMinutes?: number,
commandOutputCapture?: ProtectedCommandOutputCapture,
) => Promise<{ exitCode: number }>;
collectDiagnosticLogs?: (workDir: string) => Promise<void>;
/** Trusted unified enclave preflight and staging. */
Expand Down Expand Up @@ -91,6 +93,7 @@ interface WorkflowLogger {
interface WorkflowOptions extends WorkflowCallbacks {
logger: WorkflowLogger;
performCleanup: () => Promise<void>;
commandOutputCapture?: ProtectedCommandOutputCapture;
}

/**
Expand Down Expand Up @@ -363,7 +366,20 @@ export async function runMainWorkflow(
if (config.enableApiProxy && config.agentTimeout !== undefined) {
ensureAgentRuntimeStartMarker(config.workDir, config.proxyLogsDir);
}
const result = await dependencies.runAgentCommand(config.workDir, config.allowedDomains, config.proxyLogsDir, config.agentTimeout);
const result = options.commandOutputCapture
? await dependencies.runAgentCommand(
config.workDir,
config.allowedDomains,
config.proxyLogsDir,
config.agentTimeout,
options.commandOutputCapture,
)
: await dependencies.runAgentCommand(
config.workDir,
config.allowedDomains,
config.proxyLogsDir,
config.agentTimeout,
);

// Step 3.5: Collect diagnostic logs before containers are stopped
// Must run BEFORE performCleanup() which calls docker compose down -v.
Expand Down
13 changes: 13 additions & 0 deletions src/commands/build-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ describe('buildConfig', () => {
}))).toThrow(/primary backend "docker"/);
});

it('maps protected command output CLI options into the runtime config', () => {
const config = buildConfig(makeInputs({
options: {
...makeInputs().options,
captureCommandOutput: '/var/tmp/awf-output',
captureCommandOutputMaxBytes: '1024',
},
}));

expect(config.commandOutputCaptureDir).toBe('/var/tmp/awf-output');
expect(config.commandOutputCaptureMaxBytes).toBe(1024);
});

let savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;

beforeEach(() => {
Expand Down
4 changes: 4 additions & 0 deletions src/commands/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig {
auditDir: (options.auditDir as string | undefined) || process.env.AWF_AUDIT_DIR,
sessionStateDir:
(options.sessionStateDir as string | undefined) || process.env.AWF_SESSION_STATE_DIR,
commandOutputCaptureDir: options.captureCommandOutput as string | undefined,
commandOutputCaptureMaxBytes: options.captureCommandOutputMaxBytes === undefined
? undefined
: Number(options.captureCommandOutputMaxBytes),
runnerToolCachePath: options.runnerToolCachePath as string | undefined,
enableHostAccess: options.enableHostAccess as boolean,
networkIsolation: options.networkIsolation as boolean | undefined,
Expand Down
91 changes: 81 additions & 10 deletions src/commands/main-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ import {
disconnectEnclaveGithubGateway,
} from '../enclave/github-gateway';
import type { WrapperConfig } from '../types';
import {
prepareProtectedCommandOutputCapture,
type ProtectedCommandOutputCapture,
} from '../protected-command-output-capture';
import {
formatCloudHypervisorDockerFallbackWarning,
isCloudHypervisorUnsupportedHostError,
Expand Down Expand Up @@ -555,6 +559,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
let agentCommandStarted = false;
let hostIptablesSetup = false;
let externalRuntimeBackend: ExternalAgentRuntimeBackend | undefined;
let commandOutputCapture: ProtectedCommandOutputCapture | undefined;
let cloudHypervisorPrimaryPreflightPassed = false;
let performCleanup = buildCleanupFn(
config,
Expand Down Expand Up @@ -601,6 +606,13 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
await runDindBootstrap(config);
}
assertPersistentDebugArtifactsIsolated(config);
commandOutputCapture = prepareProtectedCommandOutputCapture(config);
if (commandOutputCapture) {
config.additionalEnv = {
...config.additionalEnv,
AWF_CAPTURE_COMMAND_OUTPUT: '1',
};
}

// Log config with redacted secrets - remove API keys entirely
// to prevent sensitive data from flowing to logger (CodeQL sensitive data logging)
Expand All @@ -626,12 +638,37 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
registerSignalHandlers({
getContainersStarted: () => containersStarted,
keepContainers: config.keepContainers,
fastKillAgentContainer: () => (
externalRuntimeBackend
? externalRuntimeBackend.stop()
: fastKillAgentContainer()
),
performCleanup: (signal) => performCleanup(signal),
fastKillAgentContainer: async () => {
if (externalRuntimeBackend) {
await externalRuntimeBackend.stop();
} else {
await fastKillAgentContainer();
}
if (commandOutputCapture) {
if (agentCommandStarted) {
await commandOutputCapture.waitForCommandCompletion();
} else {
commandOutputCapture.finishCommand(undefined, 'cancelled');
}
}
},
performCleanup: async (signal) => {
try {
await performCleanup(signal);
commandOutputCapture?.setTeardown('complete');
} catch (error) {
commandOutputCapture?.setTeardown('failed');
throw error;
} finally {
if (signal) {
commandOutputCapture?.finishCommand(
undefined,
config.keepContainers && agentCommandStarted ? 'interrupted' : 'cancelled',
);
}
commandOutputCapture?.close();
}
},
cleanupRouting: () => cleanupRoutingState(config),
});

Expand Down Expand Up @@ -674,16 +711,32 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
? adaptExternalRuntimeBackend(externalRuntimeBackend)
: undefined;
const workflowRunAgentCommand = externalWorkflowDependencies?.runAgentCommand
?? ((workDir: string, allowedDomains: string[], proxyLogsDir?: string, agentTimeoutMinutes?: number) =>
runAgentCommand(workDir, allowedDomains, proxyLogsDir, agentTimeoutMinutes, config.containerRuntime));
?? ((
workDir: string,
allowedDomains: string[],
proxyLogsDir?: string,
agentTimeoutMinutes?: number,
capture?: ProtectedCommandOutputCapture,
) => capture
? runAgentCommand(
workDir, allowedDomains, proxyLogsDir, agentTimeoutMinutes, config.containerRuntime, capture,
)
: runAgentCommand(
workDir, allowedDomains, proxyLogsDir, agentTimeoutMinutes, config.containerRuntime,
));
const trackedRunAgentCommand = async (
workDir: string,
allowedDomains: string[],
proxyLogsDir?: string,
agentTimeoutMinutes?: number,
capture?: ProtectedCommandOutputCapture,
) => {
agentCommandStarted = true;
const result = await workflowRunAgentCommand(workDir, allowedDomains, proxyLogsDir, agentTimeoutMinutes);
const result = capture
? await workflowRunAgentCommand(
workDir, allowedDomains, proxyLogsDir, agentTimeoutMinutes, capture,
)
: await workflowRunAgentCommand(workDir, allowedDomains, proxyLogsDir, agentTimeoutMinutes);
if (result.exitCode !== 0) {
emitStartupExitMarker(config, startupExitState, {
path: 'workflow-return', exitCode: result.exitCode, containersStarted,
Expand Down Expand Up @@ -757,7 +810,16 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
},
{
logger,
performCleanup,
performCleanup: async () => {
try {
await performCleanup();
commandOutputCapture?.setTeardown('complete');
} catch (error) {
commandOutputCapture?.setTeardown('failed');
throw error;
}
},
commandOutputCapture,
onHostIptablesSetup: () => {
hostIptablesSetup = true;
},
Expand All @@ -773,6 +835,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
recordWrite: 'not-attempted', cleanup: 'completed',
});
}
commandOutputCapture?.close();
console.error(`Process exiting with code: ${exitCode}`);
process.exit(exitCode);
return;
Expand All @@ -796,6 +859,14 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
cleanupOutcome = 'failed';
logger.warn('Cleanup after a fatal error failed.', cleanupError);
}
if (commandOutputCapture) {
commandOutputCapture.finishCommand(
undefined,
'failed',
);
commandOutputCapture.setTeardown(cleanupOutcome === 'completed' ? 'complete' : 'failed');
commandOutputCapture.close();
}
emitStartupExitMarker(config, startupExitState, { ...exitMarker, cleanup: cleanupOutcome });
cleanupRoutingState(config);
const fatalExitCode = findRoutingFailure(error)?.exitCode ?? 1;
Expand Down
7 changes: 7 additions & 0 deletions src/commands/validators/config-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
assertNvxSelection,
isPrimaryNvxRuntime,
} from '../../nvx/runtime-validation';
import { assertProtectedCommandOutputCaptureSupported } from '../../protected-command-output-capture';
import { assertFilesystemWritePolicyCompatibility } from '../../filesystem-policy';
import {
formatCloudHypervisorDockerFallbackWarning,
Expand Down Expand Up @@ -93,6 +94,12 @@ export function assembleAndValidateConfig(
dockerHostPathPrefix: networkOptions.dockerHostPathPrefixResolution.dockerHostPathPrefix,
});

try {
assertProtectedCommandOutputCaptureSupported(config);
} catch (error) {
logger.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
validateInfrastructureOptions(config);
try {
assertCloudHypervisorSelection(config);
Expand Down
Loading
Loading