It turns out that build_image_inner was deriving the Docker build
context from dockerfile.parent(), which is *wrong* when the
Dockerfile lives in a subdirectory of the action root. An action
with runs.image: subdir/Dockerfile would get subdir/ as its build
context instead of the action root, silently breaking every COPY
instruction that references files outside that subdirectory.
The fix is straightforward: add an explicit context_dir parameter
to the ContainerRuntime::build_image trait so callers tell us what
the context is instead of us guessing from the Dockerfile path.
The DockerBuild path in engine.rs now passes &action_dir, and the
Docker inner implementation computes the Dockerfile path relative
to context_dir via strip_prefix instead of just using file_name().
While at it, add a warning log when shlex::split fails to parse
with.args (unmatched quotes). Previously this silently fell back
to naive whitespace splitting, which is the kind of thing that
makes you stare at container logs for an hour wondering why your
quoted argument got split into three pieces.
Three correctness bugs found during review of the Docker action
execution path:
1. with.args was being split on whitespace like a caveman. An
argument like "hello world" would turn into two separate args,
which is *not* how GitHub Actions works. Use shlex::split() for
proper shell-word parsing, with a whitespace fallback for
malformed input that shlex chokes on.
2. sanitize_dockerfile_rel() happily accepted empty strings. Feed
it "" or "docker://" and it would produce an empty path, which
then joins to a directory instead of a file. The subsequent
docker build would fail with a confusing error. Let's just
reject empty paths upfront.
3. SecureEmulationRuntime silently swallowed the entrypoint
override without telling anyone. If you're running in secure
emulation mode and your action specifies runs.entrypoint, you
deserve to know it's being ignored — not left wondering why
your action isn't doing what you expect.
The previous commits got the NativeDocker path working for remote
actions, but left several holes that a code review correctly
identified. Let's fix them all.
First, local Docker actions (uses: ./my-action with a Dockerfile)
were *still* returning PreparedAction::Image instead of NativeDocker.
Same class of bug we just fixed for remote actions, hiding one
function call away. They now go through NativeDocker and parse the
local action.yml for entrypoint/args.
Second, runs.entrypoint and runs.args from action.yml were being
completely ignored. Docker actions that declare their entrypoint in
action.yml (which is, you know, *a lot of them*) would silently
use the wrong entrypoint. Add an entrypoint parameter to the
ContainerRuntime trait and thread it through all four implementations:
Docker sets Config.entrypoint, Podman passes --entrypoint, and the
emulation runtimes accept-and-ignore it.
Third, with.args from workflow steps (uses: docker://alpine with
args: "echo hello") was not being passed as container CMD. It now
overrides runs.args when present, matching GitHub Actions behavior.
While at it:
- Extract sanitize_dockerfile_rel into a real function instead of
having the tests duplicate the logic and test their own copy.
Testing a copy of your code instead of the actual code is not
what I'd call confidence-inspiring.
- Add canonicalize() defense-in-depth after Dockerfile path
resolution to catch symlink escapes.
- Document the build_image_inner context directory invariant.
Three bugs in the Docker action execution path from the previous
commit:
1. The macOS emulation entrypoint override (`bash -l -c`) was applied
*unconditionally*, even when cmd was empty (NativeDocker path). That
means Docker actions running on macOS emu images would get bash with
no argument — which either hangs forever or exits immediately. The
image's real ENTRYPOINT gets discarded either way. This is not great.
Fix: capture `has_cmd` before cmd_vec is moved into the config, only
apply the bash wrapper when there's actually a command to wrap.
2. The `dockerfile_rel` extracted from action.yml's `runs.image` was
not sanitized after stripping the `docker://` prefix. A malicious
action.yml with `docker:///etc/shadow` or `../../sensitive` would
escape the action directory via Path::join's absolute-path behavior
or dotdot traversal.
Fix: strip leading slashes and reject any path containing `..`.
3. Emulation mode returned exit_code 0 for Docker actions it *didn't
actually run*. Users got a green checkmark for actions that were
silently skipped. Confusion ensues.
Fix: return exit_code 1 with a clear stderr message explaining the
action was not executed and needs --runtime docker.
While at it, add tests for all three fixes: NativeDocker variant
construction, dockerfile path sanitization (6 cases), and emulation
empty-cmd failure behavior.
Third-party GitHub Actions that use Docker (like super-linter) were
silently passing without ever *actually running*. The engine would
resolve the action, pick a Docker image, and then... run
`echo 'Would execute GitHub action: ...'` inside it. Every single
time. Regardless of runtime mode. Confusion ensues.
It turns out there were two separate failures conspiring here:
1. `prepare_action()` would error out on `ActionType::DockerBuild`
with "not yet supported", fall back to `determine_action_image()`,
and cheerfully return `node:20-slim` for super-linter. This is
not great.
2. The `PreparedAction::Image` execution branch had three sub-paths
for is_docker, is_local, and everything else — and *all three*
just ran echo commands. The image was resolved correctly and then
completely ignored.
The fix has several parts:
- Add a `NativeDocker` variant to `PreparedAction` that means "run
this image with its built-in ENTRYPOINT, no command override."
Docker registry actions and DockerBuild actions both use this.
- Implement DockerBuild properly: clone the repo, resolve the
Dockerfile path from action.yml, build it, return the tag.
Uses the existing `shallow_clone` and `runtime.build_image`.
- Fix `build_image_inner` to tar the *full context directory*
instead of just the Dockerfile. The old code had `_context_dir`
sitting right there, computed and unused. COPY instructions in
Dockerfiles need the context, obviously.
- Allow empty `cmd` in `run_container` to mean "use the image's
default ENTRYPOINT/CMD". The Docker impl now sets `config.cmd =
None` when cmd is empty. Podman already handled this correctly.
The existing `PreparedAction::Image` path with all its special-cased
action handling (actions-rs, checkout, etc.) is completely untouched.
Closes#59
The validate subcommand was calling std::process::exit(1) when a
directory couldn't be read, which is a rather aggressive response
to a permission error. Especially when the code four lines above
handles a *missing* path by setting validation_failed and moving
on to the next one. Consistency is nice. Let's have some.
Split the match from the method chain (because continue is a
statement, not an expression, and Rust has opinions about that)
and replaced the exit(1) with the same continue pattern.
While at it, slap #[must_use] on ContainerOutput so the compiler
will yell at anyone who discards a run_container result without
checking exit_code. All current callers already bind it, so this
is purely forward-looking — but the kind of bug it prevents is
the silent-misexecution kind, and those are nobody's favorite.
The previous commit fixed a bunch of bugs but left a few loose
ends. The next_job() function still had a redundant bounds check
that previous_job() already had cleaned up — the .filter() call
makes the inner `if workflow_idx >= self.workflows.len()` dead
code. Let's not leave half-finished refactors lying around.
While at it, add tests for the three behavioral changes that
*really* should have had tests from the start: emulation runtime
returning Ok on non-zero exit codes, log processor not panicking
on multi-byte UTF-8 near bracket boundaries, and step validator
correctly rejecting steps with only a name field.
Also fix formatting (cargo fmt) and a clippy warning about items
defined after the test module.
It turns out that build_image_inner() in docker.rs was calling
.elapsed() on a SystemTime to compute the tar mtime. That gives
you "seconds since modification" — which is *not* what mtime
means. Mtime is seconds since the Unix epoch. The fix is
.duration_since(UNIX_EPOCH) like a normal person would use.
While at it, the docker logs() call was passing None for options,
which means it wasn't actually requesting stdout or stderr. So
we were collecting logs from a stream that might not have any.
Explicitly set stdout: true and stderr: true.
The emulation runtime had a fun behavioral mismatch with Docker
and Podman: it returned Err on non-zero exit codes, swallowing
all stdout/stderr output. Docker and Podman return Ok with the
exit code and let the caller decide what to do. The engine
already handles non-zero exit codes in the Ok path, so the
emulation was just silently eating useful output for no reason.
The UI had a bounds check in next_job() that was mysteriously
absent from previous_job() — the kind of inconsistency that
waits patiently for someone to hit a stale workflow index and
get a panic. Added the same .filter() guard.
String slicing in the log processor wasn't checking char
boundaries, which is fine until someone's log contains a
multi-byte UTF-8 character before a bracket. Added
is_char_boundary() checks.
Step validation was accepting steps with only a 'name' field
and no 'uses' or 'run', which is not a valid step in GitHub
Actions. Fixed the validation to require at least one of the
two fields that actually *do* something.
Replaced .expect() calls on directory reads in main.rs with
proper error handling. Panicking because a directory isn't
readable is not great user experience.
- Fix clippy needless_borrows_for_generic_args warning
- Change &pid.to_string() to pid.to_string() for taskkill /PID argument
- Ensure clippy passes with -D warnings on Windows builds
- Add emulation support for dtolnay/rust-toolchain@ actions
- Include Rust and Cargo availability checks for dtolnay toolchain action
- Improve action detection logging for dtolnay Rust toolchain
Related to #49
- Add ignore crate dependency to executor and runtime crates
- Implement gitignore-aware file copying in engine.rs and emulation.rs
- Support for .gitignore patterns, whitelist rules, and default ignore patterns
- Maintain backward compatibility with projects without .gitignore files
- Add proper error handling and debug logging for ignored files
This ensures that files marked in .gitignore are not copied to containers
or emulation workspaces, improving performance and security.
Security Features:
- Implement secure emulation runtime with command sandboxing
- Add command validation, filtering, and dangerous pattern detection
- Block harmful commands like 'rm -rf /', 'sudo', 'dd', etc.
- Add resource limits (CPU, memory, execution time, process count)
- Implement filesystem isolation and access controls
- Add environment variable sanitization
- Support shell operators (&&, ||, |, ;) with proper parsing
New Runtime Mode:
- Add 'secure-emulation' runtime option to CLI
- Update UI to support new runtime mode with green security indicator
- Mark legacy 'emulation' mode as unsafe in help text
- Default to secure mode for local development safety
Documentation:
- Create comprehensive security documentation (README_SECURITY.md)
- Update main README with security mode information
- Add example workflows demonstrating safe vs dangerous commands
- Include migration guide and best practices
Testing:
- Add comprehensive test suite for sandbox functionality
- Include security demo workflows for testing
- Test dangerous command blocking and safe command execution
- Verify resource limits and timeout functionality
Code Quality:
- Fix all clippy warnings with proper struct initialization
- Add proper error handling and user-friendly security messages
- Implement comprehensive logging for security events
- Follow Rust best practices throughout
This addresses security concerns by preventing accidental harmful
commands while maintaining full compatibility with legitimate CI/CD
workflows. Users can now safely run untrusted workflows locally
without risk to their host system.
- Extracted functionality from the `src/` directory into individual crates within the `crates/` directory. This improves modularity, organization, and separation of concerns.
- Migrated modules include: models, evaluator, ui, gitlab, utils, logging, github, matrix, executor, runtime, parser, and validators.
- Removed the original source files and directories from `src/` after successful migration.
- This change sets the stage for better code management and potentially independent development/versioning of workspace members.