Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ sudo awf --allow-domains github.com -- curl https://api.github.com

The `--` separator divides firewall options from the command to run.

To inspect the API proxy endpoints and models without running an agent command,
use `awf --reflect`. It prints the `/reflect` JSON response to stdout.

## Feature highlights

- **Declarative config support**: `--config <path>` with JSON/YAML + published JSON Schema
Expand Down
27 changes: 24 additions & 3 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
#!/bin/bash
set -e

configure_output_routing() {
if [ "${AWF_COMMAND_STDOUT_ONLY:-}" != "1" ]; then
return
fi
# Keep entrypoint diagnostics off the command's stdout. File descriptor 3
# preserves the original stdout exclusively for the user command.
exec 3>&1
exec 1>&2
}

run_command_with_stdout() {
if [ "${AWF_COMMAND_STDOUT_ONLY:-}" = "1" ]; then
"$@" >&3 3>&-
else
"$@"
fi
}

print_banner() {
echo "[entrypoint] Agentic Workflow Firewall - Agent Container"
echo "[entrypoint] =================================="
Expand Down Expand Up @@ -1383,7 +1401,7 @@ run_chroot_command() {
LD_PRELOAD_CMD="export LD_PRELOAD=${ONE_SHOT_TOKEN_LIB};"
fi

run_agent_with_token_protection chroot /host /bin/bash -c "
run_command_with_stdout run_agent_with_token_protection chroot /host /bin/bash -c "
cd '${CHROOT_WORKDIR}' 2>/dev/null || cd / 2>/dev/null || true
trap '${CLEANUP_CMD}' EXIT
${LD_PRELOAD_CMD}
Expand Down Expand Up @@ -1476,14 +1494,15 @@ run_non_chroot_command() {
export LD_PRELOAD=/usr/local/lib/one-shot-token.so

if [ -n "$CAPS_TO_DROP" ]; then
run_agent_with_token_protection capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")"
run_command_with_stdout run_agent_with_token_protection capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")"
else
# No capabilities to drop - just switch to unprivileged user
run_agent_with_token_protection gosu awfuser "$@"
run_command_with_stdout run_agent_with_token_protection gosu awfuser "$@"
fi
}

main() {
configure_output_routing
print_banner
setup_user_identity
configure_dns
Expand All @@ -1502,4 +1521,6 @@ else
fi
}

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
52 changes: 52 additions & 0 deletions src/agent-entrypoint-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { spawnSync } from 'child_process';
import * as path from 'path';

describe('agent entrypoint output routing', () => {
it('keeps complete command stdout parseable as JSON', () => {
const entrypoint = path.join(__dirname, '..', 'containers', 'agent', 'entrypoint.sh');
const result = spawnSync(
'/bin/bash',
[
'-c',
[
'source "$1"',
'export AWF_COMMAND_STDOUT_ONLY=1',
'configure_output_routing',
'print_banner',
'run_command_with_stdout printf \'%s\' \'{"models":[]}\'',
].join('\n'),
'entrypoint-output-test',
entrypoint,
],
{ encoding: 'utf8' },
);

expect(result.status).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ models: [] });
expect(result.stderr).toContain('[entrypoint] Agentic Workflow Firewall');
expect(result.stdout).not.toContain('[entrypoint]');
});

it('preserves normal entrypoint output when stdout-only mode is disabled', () => {
const entrypoint = path.join(__dirname, '..', 'containers', 'agent', 'entrypoint.sh');
const result = spawnSync(
'/bin/bash',
[
'-c',
[
'source "$1"',
'configure_output_routing',
'print_banner',
'run_command_with_stdout printf \'%s\' \'command-output\'',
].join('\n'),
'entrypoint-output-test',
entrypoint,
],
{ encoding: 'utf8' },
);

expect(result.status).toBe(0);
expect(result.stdout).toContain('[entrypoint] Agentic Workflow Firewall');
expect(result.stdout).toContain('command-output');
});
});
5 changes: 5 additions & 0 deletions src/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,4 +472,9 @@ program
' Written to <workDir>/diagnostics/ (or <audit-dir>/diagnostics/ when set).',
false
)
.option(
'--reflect',
'Start AWF, query the API proxy /reflect endpoint, and print its JSON response',
false
)
.argument('[args...]', 'Command and arguments to execute (use -- to separate from options)');
30 changes: 30 additions & 0 deletions src/commands/main-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,36 @@ describe('createMainAction', () => {
expect.stringContaining('No command specified')
);
});

it('runs the reflection endpoint when --reflect is set', async () => {
const action = createMainAction(getOptionValueSource);
await action([], { reflect: true });
expect(mockedValidateOptions.validateOptions).toHaveBeenCalledWith(
expect.anything(),
'curl --fail --silent --show-error --noproxy "*" http://api-proxy:10000/reflect'
);
expect(mockedOptionParsers.joinShellArgs).not.toHaveBeenCalled();
expect(mockedCliWorkflow.runMainWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
additionalEnv: expect.objectContaining({
AWF_COMMAND_STDOUT_ONLY: '1',
}),
}),
expect.anything(),
expect.anything(),
);
});
});

describe('when --reflect is used with a command', () => {
it('exits with code 1 and prints a usage error', async () => {
const action = createMainAction(getOptionValueSource);
await expect(action(['echo hi'], { reflect: true })).rejects.toThrow('process.exit: 1');
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--reflect cannot be used with a command')
);
});
});

describe('when single arg is provided', () => {
Expand Down
20 changes: 18 additions & 2 deletions src/commands/main-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ const SENSITIVE_CONFIG_KEYS = new Set([
'sensitiveAllowedDomains',
]);

const REFLECT_COMMAND = 'curl --fail --silent --show-error --noproxy "*" http://api-proxy:10000/reflect';
Comment thread
lpcox marked this conversation as resolved.

function redactConfigForLogging(config: WrapperConfig): Record<string, unknown> {
const redactedConfig: Record<string, unknown> = {};
for (const [key, value] of Object.entries(config)) {
Expand Down Expand Up @@ -192,12 +194,18 @@ type OptionSourceResolver = (optionName: string) => string | undefined;
*/
export function createMainAction(getOptionValueSource: OptionSourceResolver) {
return async function mainAction(args: string[], options: Record<string, unknown>): Promise<void> {
const reflect = options.reflect === true;

// Require -- separator for passing command arguments
if (args.length === 0) {
if (args.length === 0 && !reflect) {
console.error('Error: No command specified. Use -- to separate command from options.');
console.error('Example: awf --allow-domains github.com -- curl https://api.github.com');
process.exit(1);
}
if (reflect && args.length > 0) {
console.error('Error: --reflect cannot be used with a command.');
process.exit(1);
}

// Command argument handling:
//
Expand Down Expand Up @@ -225,13 +233,21 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
// - We need variables to expand in CONTAINER ($HOME → /root or /home/runner)
// - The $$$$ escaping pattern requires literal $ preservation
//
const agentCommand = args.length === 1 ? args[0] : joinShellArgs(args);
const agentCommand = reflect
? REFLECT_COMMAND
: args.length === 1 ? args[0] : joinShellArgs(args);

applyConfigFilePrecedence(options as Record<string, unknown>, getOptionValueSource);

// Validate all options and assemble the config.
// Calls process.exit(1) on any validation failure.
const config = validateOptions(options as Record<string, unknown>, agentCommand);
if (reflect) {
config.additionalEnv = {
...config.additionalEnv,
AWF_COMMAND_STDOUT_ONLY: '1',
};
}

// Apply --docker-host override for AWF's own container operations.
// This must be called before startContainers/stopContainers/runAgentCommand.
Expand Down
14 changes: 5 additions & 9 deletions tests/integration/api-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,15 +254,13 @@ describe('API Proxy Sidecar', () => {
expect(result.stdout).toContain('"copilot":true');
}, 180000);

test('should exclude GITHUB_API_URL from agent when api-proxy is enabled (GHES fix)', async () => {
test('should preserve GITHUB_API_URL while routing Copilot through api-proxy', async () => {
// On GHES, workflows set GITHUB_API_URL to the GHES API endpoint (e.g., https://api.ghes-host).
// When api-proxy is enabled, GITHUB_API_URL should NOT be passed to the agent container,
// because Copilot CLI would use it for Copilot API requests, which don't exist on GHES API.
// Instead, the agent should use COPILOT_API_URL pointing to the proxy, which correctly
// routes to api.enterprise.githubcopilot.com.
// The agent still needs that endpoint for GitHub API operations. Copilot-specific calls
// use COPILOT_API_URL, which points to the proxy and routes to the Copilot API.
// See: github/gh-aw#20875
const result = await runner.run(
'bash -c "if [ -z \\"$GITHUB_API_URL\\" ]; then echo GITHUB_API_URL_NOT_SET; else echo GITHUB_API_URL=$GITHUB_API_URL; fi"',
'bash -c "echo GITHUB_API_URL=$GITHUB_API_URL; echo COPILOT_API_URL=$COPILOT_API_URL"',
{
allowDomains: ['api.githubcopilot.com'],
enableApiProxy: true,
Expand All @@ -278,9 +276,7 @@ describe('API Proxy Sidecar', () => {
);

expect(result).toSucceed();
// GITHUB_API_URL should NOT be set in agent container when api-proxy is enabled
expect(result.stdout).toContain('GITHUB_API_URL_NOT_SET');
// COPILOT_API_URL should point to the proxy instead
expect(result.stdout).toContain('GITHUB_API_URL=https://api.ghes-host.example.com');
expect(result.stdout).toContain(`COPILOT_API_URL=http://${API_PROXY_IP}:10002`);
}, 180000);

Expand Down
14 changes: 10 additions & 4 deletions tests/integration/chroot-edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,14 +209,20 @@ describe('Chroot Edge Cases', () => {
batch = await runBatch(runner, [
// pivot_root - blocked in seccomp profile
{ name: 'pivot_root', command: 'mkdir -p /tmp/newroot /tmp/putold && pivot_root /tmp/newroot /tmp/putold 2>&1 || unshare --mount pivot_root /tmp/newroot /tmp/putold 2>&1' },
// mount after capability drop - mount syscall allowed in seccomp but CAP_SYS_ADMIN should be dropped
{ name: 'mount_tmpfs', command: 'mount -t tmpfs tmpfs /tmp/test-mount-$$ 2>&1' },
// Call mount directly so utility preflight checks cannot mask the capability denial.
{
name: 'mount_tmpfs',
command: `python3 -c 'import ctypes, os, sys, tempfile; target = tempfile.mkdtemp(); libc = ctypes.CDLL(None, use_errno=True); result = libc.mount(b"tmpfs", target.encode(), b"tmpfs", 0, None); error = ctypes.get_errno(); os.rmdir(target) if result != 0 else None; print(os.strerror(error)); sys.exit(0 if result == 0 else error)' 2>&1`,
},
// unshare namespace creation - requires CAP_SYS_ADMIN
{ name: 'unshare_mount', command: 'unshare --mount /bin/true 2>&1' },
// nsenter - requires CAP_SYS_ADMIN
{ name: 'nsenter', command: 'nsenter --mount --target 1 /bin/true 2>&1' },
// umount - blocked in seccomp profile
{ name: 'umount', command: 'umount /tmp 2>&1' },
// Call umount2 directly so utility preflight checks cannot mask the seccomp denial.
{
name: 'umount',
command: `python3 -c 'import ctypes, os, sys; libc = ctypes.CDLL(None, use_errno=True); result = libc.umount2(b"/proc", 0); error = ctypes.get_errno(); print(os.strerror(error)); sys.exit(0 if result == 0 else error)' 2>&1`,
},
// setuid escalation - no-new-privileges should prevent
{ name: 'no_new_privs', command: 'cat /proc/self/status | grep NoNewPrivs 2>&1' },
], {
Expand Down
Loading