Repository navigation
feat(agent-sandbox): log denied-command attempts via hardened-image shim (#2662 option 2) - #3019
Conversation
The minimal-PATH hardened image (option 1 of microsoft#2662) prevents denied network/infra CLIs but blocks them silently ('command not found' / EACCES). The issue is explicit that for compliance, detecting the attempt matters as much as preventing it. Add docker/agt-deny-shim.py — a stdlib-only Python logging shim — and route the denied CLIs (curl, az, kubectl, terraform, …) to it in Dockerfile.sandbox, both at each binary's real path (absolute-path calls) and under its name in the pinned PATH dir (by-name calls). Each attempt writes a structured command_denied JSON record to stderr (captured in SandboxResult.stderr), optionally appends to $AGT_DENIED_LOG, and exits 126 so the real command never runs. The shim is Python because the image strips the execute bit off every shell; shells/interpreters/encoders stay disabled-but-unlogged. Docker-free tests cover the shim behavior and assert the Dockerfile wiring. This is option 2 of tracker microsoft#2662. Refs microsoft#2662 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR Review Summary
Verdict: AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims. |
Imran Siddique (imran-siddique)
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — 3 blockers, 5 required fixes, 2 nits. The shim design is sound and the stdlib-only constraint is met. Address these before merge.
Blocker 1 — Schema break: ts vs timestamp
command_denied records use "ts" but every other AGT audit record (compliance.py, flight_recorder.py, time_travel_debugger.py) uses hard bracket e["timestamp"]. A mixed audit log raises KeyError on every shim-sourced entry. Rename to "timestamp".
Blocker 2 — EACCES contract silently broken
Stage 2 strips execute bits to guarantee PermissionError on absolute-path calls. Stage 3's ln -sf to a chmod 0755 shim restores executability — the error changes from PermissionError (EACCES) to exit 126. Callers that catch OSError/PermissionError for the block signal stop seeing denials. Either document this contract change with a migration note, or update existing callers that gate on PermissionError.
Blocker 3 — AGT_DENIED_LOG follows symlinks unconditionally
open(log_path, "a") follows symlinks. If the orchestrator or a compromised env injects a symlink for AGT_DENIED_LOG, the shim appends to whatever it resolves to. Fix: validate the path stays within an expected prefix, or use os.open with os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW.
Required fix 1 — Glob injection in Dockerfile for-loop
for bin in $DENIED_LOGGED_BIN_NAMES with set -eux but no set -f: glob metacharacters in a custom --build-arg expand against the filesystem. Add set -f before the loop.
Required fix 2 — ln -sf into sandbox-bin overwrites allowed binaries
A binary in both ALLOWED_BIN_NAMES and DENIED_LOGGED_BIN_NAMES is silently overwritten with the shim. Add [ -e "/usr/local/sandbox-bin/$bin" ] && continue before the ln -sf.
Required fix 3 — Shebang fragility
#!/usr/bin/env python3 depends on /usr/bin/env being executable. Stage 2's chmod pass could strip it. Use an absolute shebang or have symlinks exec python3 /usr/local/lib/agt-deny-shim.py directly.
Required fix 4 — Non-atomic stderr writes
Two sequential sys.stderr.write() calls interleave with concurrent deny events. Merge into one write:
sys.stderr.write(line + "\n" + f"agt-sandbox: command denied: {invoked}\n")Required fix 5 — Silent log failure
except OSError: pass drops the file-based log record with no signal. Replace with:
except OSError as exc:
sys.stderr.write(f"agt-sandbox: warning: could not write AGT_DENIED_LOG: {exc}\n")Nit 1 — Document the minimum required DENIED_LOGGED_BIN_NAMES set; a single-entry build-arg silently under-restricts the image with no warning.
Nit 2 — if argv and argv[0] is unreachable via main(sys.argv). Remove or add a direct test for main([]).
Blocker 1: rename the record field ts -> timestamp to match the AGT audit-record convention. Blocker 3: open AGT_DENIED_LOG with os.open(O_NOFOLLOW) so a planted symlink cannot redirect the append. Required fixes: add 'set -f' to the Dockerfile loop (glob injection); skip routing when the name is already an allowed sandbox-bin entry (allow-list precedence); use an absolute python shebang instead of /usr/bin/env; merge the two stderr writes into one (atomicity); surface AGT_DENIED_LOG write failures on stderr instead of swallowing them. Blocker 2 (behavior change EACCES -> exit 126): documented in the shim, README, and the DENY_EXIT_CODE comment. Verified no in-tree caller gates on PermissionError, so no callers need updating. Nit 1: README documents that DENIED_LOGGED_BIN_NAMES replaces (not extends) the set. Nit 2: added a direct test for main([]). Refs microsoft#2662 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks Imran Siddique (@imran-siddique) — thorough review. Addressed in 77d58c0. Point by point: Blocker 1 ( Blocker 2 (EACCES → exit 126) ✅ Documented the contract change in the shim header, the Blocker 3 (symlink-following on Required 1 (glob injection) ✅ Added Required 2 (allow-list clobber) ✅ Added Required 3 (shebang fragility) ✅ Changed to the absolute Required 4 (non-atomic stderr) ✅ Merged the structured record and human line into a single Required 5 (silent log failure) ✅ Nit 1 (build-arg under-restriction) ✅ README now notes Nit 2 ( Tests: 17 passing (12 shim + 5 Dockerfile-text). The hardened image still isn't built in CI, so the image wiring remains validated by the text-level Dockerfile tests + the Docker-free shim tests. Ready for another look. |
Imran Siddique (imran-siddique)
left a comment
There was a problem hiding this comment.
Thorough resolution — all 8 items addressed. The symlink-safe log write (O_NOFOLLOW), single atomic write() for stderr, absolute shebang, set -f before the loop, allow-list wins over deny-list, and the behavior-change documentation are all exactly right. 17 tests passing. Merging.
8a834db
into
microsoft:main
…him (microsoft#2662 option 2) (microsoft#3019) * feat(agent-sandbox): log denied-command attempts via hardened-image shim The minimal-PATH hardened image (option 1 of microsoft#2662) prevents denied network/infra CLIs but blocks them silently ('command not found' / EACCES). The issue is explicit that for compliance, detecting the attempt matters as much as preventing it. Add docker/agt-deny-shim.py — a stdlib-only Python logging shim — and route the denied CLIs (curl, az, kubectl, terraform, …) to it in Dockerfile.sandbox, both at each binary's real path (absolute-path calls) and under its name in the pinned PATH dir (by-name calls). Each attempt writes a structured command_denied JSON record to stderr (captured in SandboxResult.stderr), optionally appends to $AGT_DENIED_LOG, and exits 126 so the real command never runs. The shim is Python because the image strips the execute bit off every shell; shells/interpreters/encoders stay disabled-but-unlogged. Docker-free tests cover the shim behavior and assert the Dockerfile wiring. This is option 2 of tracker microsoft#2662. Refs microsoft#2662 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(agent-sandbox): address review on deny-shim (microsoft#3019) Blocker 1: rename the record field ts -> timestamp to match the AGT audit-record convention. Blocker 3: open AGT_DENIED_LOG with os.open(O_NOFOLLOW) so a planted symlink cannot redirect the append. Required fixes: add 'set -f' to the Dockerfile loop (glob injection); skip routing when the name is already an allowed sandbox-bin entry (allow-list precedence); use an absolute python shebang instead of /usr/bin/env; merge the two stderr writes into one (atomicity); surface AGT_DENIED_LOG write failures on stderr instead of swallowing them. Blocker 2 (behavior change EACCES -> exit 126): documented in the shim, README, and the DENY_EXIT_CODE comment. Verified no in-tree caller gates on PermissionError, so no callers need updating. Nit 1: README documents that DENIED_LOGGED_BIN_NAMES replaces (not extends) the set. Nit 2: added a direct test for main([]). Refs microsoft#2662 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: jlaportebot <jlaportebot@gmail.com>
Summary
Option 2 of tracker #2662: convert silently-blocked denied commands in the hardened sandbox image into logged denials, so an agent's attempt to shell out to a dangerous CLI is recorded — not just refused.
Option 1 (the minimal-PATH image, #2713) already prevents denied network/infra CLIs, but a bare "command not found" /
EACCESproduces no AGT signal. The issue is explicit: "detecting the attempt matters as much as preventing the outcome."What changed
docker/agt-deny-shim.py— a stdlib-only Python logging shim. On invocation it writes a structuredcommand_deniedJSON record to stderr (captured inSandboxResult.stderr), optionally appends it to$AGT_DENIED_LOG, and exits126so the real command never runs.docker/Dockerfile.sandbox— a new stage routes the denied network/infra CLIs (curl,wget,ssh,git,az,aws,gcloud,kubectl,terraform,helm,ansible,apt, …) to the shim, installed both at each binary's real path (explicit-dir iteration, so absolute-path calls are caught) and under its name in the pinned PATH dir (so by-name calls are logged instead of failing silently). Extend via theDENIED_LOGGED_BIN_NAMESbuild-arg.README.md— documents the logging-deny behavior.tests/test_deny_shim.py— Docker-free tests.Design notes
sh,bash, …), so a shell shim could not run;python3is an allowed interpreter. Shells, interpreters, and encoders stay execute-bit-stripped — disabled but not logged; the network/infra tooling is what carries the compliance signal.$AGT_DENIED_LOGnever turns a denial into a hard error; the command is still denied.code_scanner.py), which already host-side blocks subprocess and process-spawning calls in sandboxed code. Option 3 (AppArmor) remains open on the tracker.Testing
tests/test_deny_shim.py— 10 tests covering the shim (exit 126, structured + human-readable records,argv[0]-derived binary name, audit-log append, unwritable-log resilience) and the Dockerfile wiring (parsed as text). ExistingTestMinimalPathSandboxImagestill passes (15 passed total).Note:
Dockerfile.sandboxis an opt-in image that is not built in CI (the provider falls back topython:3.11-slimwhen it is absent), so the image wiring is validated by inspection + the text-level Dockerfile tests, and the shim behavior by the Docker-free unit tests.Refs #2662 (tracker — does not close it).
🤖 Generated with Claude Code