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
42 changes: 42 additions & 0 deletions crates/akua-cli/src/verbs/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ use akua_core::{
/// the PathError message stays colocated with its consumer.
const STRICT_MARKER: &str = "strict mode requires every chart";

/// Substring of the `PathError::Escape` Display, sniffed out of the
/// same KCL plugin-panic envelope as STRICT_MARKER. The sandboxed
/// render path collapses all errors to `KclEval(string)`, which is
/// why we can't pattern-match on the typed `PathError::Escape`
/// variant here — only the in-process render path keeps that typing.
const ESCAPE_MARKER: &str = "escapes the Package directory";

/// User-facing remediation for `E_PATH_ESCAPE`. Emitted as the
/// `suggestion` field on the structured error so agents have a
/// machine-readable next-action without parsing the `docs/errors/`
/// page. Kept identical across the sandboxed (KclEval-string) and
/// in-process (typed PathError::Escape) match arms.
const PATH_ESCAPE_SUGGESTION: &str = "Two ways out: \
(1) vendor the dependency as a subdirectory of this Package and reference it with a Package-relative path (e.g. `./vendor/<name>`); or \
(2) declare it in `akua.toml` `[dependencies]` and reference the resolved alias (`charts.<name>.path` for Helm charts; `import <alias>` for KCL/Akua packages). \
See docs/errors/E_PATH_ESCAPE.md.";

#[derive(Debug, Clone)]
pub struct RenderArgs<'a> {
pub package_path: &'a Path,
Expand Down Expand Up @@ -125,6 +142,10 @@ impl RenderError {
"Declare the chart in `akua.toml` and `import charts.<name>`, then pass `chart = <name>.path` to `helm.template`.",
)
.with_default_docs()
} else if msg.contains(ESCAPE_MARKER) {
StructuredError::new(codes::E_PATH_ESCAPE, msg.clone())
.with_suggestion(PATH_ESCAPE_SUGGESTION)
.with_default_docs()
} else {
StructuredError::new(codes::E_RENDER_KCL, msg.clone()).with_default_docs()
}
Expand All @@ -139,6 +160,11 @@ impl RenderError {
"Declare the chart in `akua.toml` and `import charts.<name>`, then pass `chart = <name>.path` to `helm.template`.",
)
.with_default_docs(),
RenderError::PackageK(PackageKError::PathEscape(
inner @ akua_core::kcl_plugin::PathError::Escape { .. },
)) => StructuredError::new(codes::E_PATH_ESCAPE, inner.to_string())
.with_suggestion(PATH_ESCAPE_SUGGESTION)
.with_default_docs(),
RenderError::PackageK(PackageKError::PathEscape(inner)) => StructuredError::new(
codes::E_PATH_ESCAPE,
inner.to_string(),
Expand Down Expand Up @@ -487,6 +513,22 @@ mod tests {
use std::fs;
use tempfile::TempDir;

/// `to_structured`'s `KclEval` arm sniffs the message for
/// `STRICT_MARKER` and `ESCAPE_MARKER` to recover a typed error
/// code from KCL's opaque plugin-panic envelope. If one marker
/// were a substring of the other, the order-dependent
/// `if/else if` chain would silently misroute one of them.
/// Pin both markers as disjoint here so a future edit to either
/// can't break this without tripping the test.
#[test]
fn render_error_markers_are_substring_disjoint() {
assert!(
!STRICT_MARKER.contains(ESCAPE_MARKER) && !ESCAPE_MARKER.contains(STRICT_MARKER),
"STRICT_MARKER and ESCAPE_MARKER must not be substrings of each other; \
got STRICT={STRICT_MARKER:?} ESCAPE={ESCAPE_MARKER:?}"
);
}

const MINIMAL_PACKAGE: &str = r#"
schema Input:
replicas: int = 2
Expand Down
56 changes: 56 additions & 0 deletions crates/akua-cli/tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,62 @@ fn init_then_render_without_inputs_flag_uses_scaffold_inputs_example() {
assert!(app.join("deploy/000-configmap-hello.yaml").is_file());
}

#[test]
fn render_path_escape_emits_e_path_escape_with_remediation_suggestion() {
// Regression for #7: a Package that calls a path-taking plugin
// with a path that escapes its own directory must surface
// E_PATH_ESCAPE with an actionable `suggestion` field naming both
// remediations (vendor under the Package, or declare in akua.toml
// and reference the resolved alias). Pre-fix: the suggestion was
// a generic "no `..` escape" line that gave no path forward.
let dir = tempdir();
let pkg = dir.path().join("escaper");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
pkg.join("akua.toml"),
"[package]\nname = \"escaper\"\nversion = \"0.1.0\"\nedition = \"akua.dev/v1alpha1\"\n[dependencies]\n",
)
.unwrap();
// pkg.render takes a path that escapes the Package dir. The
// sandboxed render path collapses this to KclEval(string), so
// the test also exercises the ESCAPE_MARKER stringly-typed
// sniffer in render.rs::to_structured.
std::fs::write(
pkg.join("package.k"),
"import akua.pkg\n\
_ = pkg.render(pkg.Render { path = \"../../../etc\" })\n\
resources = _\n",
)
.unwrap();

let out = run(&pkg, &["render", "--out", "./deploy", "--json"]);
assert_exit(&out, 1);

let stderr = String::from_utf8_lossy(&out.stderr);
let parsed: serde_json::Value =
serde_json::from_str(stderr.trim()).expect("structured error on stderr");

assert_eq!(
parsed["code"], "E_PATH_ESCAPE",
"expected E_PATH_ESCAPE, got {}",
parsed["code"]
);

let suggestion = parsed["suggestion"].as_str().unwrap_or("");
assert!(
suggestion.contains("vendor"),
"suggestion should mention vendoring as a remediation, got: {suggestion}"
);
assert!(
suggestion.contains("akua.toml"),
"suggestion should mention akua.toml as the alternative remediation, got: {suggestion}"
);
assert!(
suggestion.contains("docs/errors/E_PATH_ESCAPE.md"),
"suggestion should link to the dedicated error doc page, got: {suggestion}"
);
}

#[test]
fn render_missing_package_surfaces_structured_error_on_stderr() {
let dir = tempdir();
Expand Down
85 changes: 85 additions & 0 deletions docs/errors/E_PATH_ESCAPE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# `E_PATH_ESCAPE` — plugin path escapes the Package directory

## What happened

A KCL plugin (`helm.template`, `kustomize.build`, `pkg.render`, …) was called with a path argument that resolved to a location **outside** the Package's own directory. Akua's render sandbox refuses these by design: the Package directory is the only filesystem region a render can read from, and `..` traversal / symlink escape is the most common way an untrusted Package can try to break out.

Typical message:

```
plugin path `../upstream` resolved to `/private/tmp/spike1/upstream`,
which escapes the Package directory `/private/tmp/spike1/install`
```

## Why akua refuses

`akua render` runs each Package inside a wasmtime sandbox with read-only filesystem preopens scoped to the Package directory. A path that resolves outside that root is — by construction — unreachable through the sandbox's capabilities. We surface the error early instead of letting it manifest as a confusing wasmtime open-file failure deeper in the render.

See [`docs/security-model.md`](../security-model.md) for the full threat model.

## How to fix it

Two correct paths, in order of preference:

### 1. Vendor the dependency as a subdirectory

If you control the layout, move (or copy) the upstream into a subdirectory of your Package:

```
my-install/
├── akua.toml
├── package.k
└── vendor/
└── upstream/
├── akua.toml
└── package.k
```

Then your plugin call uses a Package-relative path:

```kcl
_up = pkg.render({
path = "./vendor/upstream"
inputs = { ... }
})
```

This is the right answer for monorepo / co-developed pairs where vendoring is acceptable. The vendored copy is part of the Package's signed surface.

### 2. Declare the dep in `akua.toml` and reference the resolved alias

For separately versioned dependencies (especially OCI-published ones), declare it in `akua.toml`:

```toml
# akua.toml
[dependencies]
upstream = { oci = "oci://example.com/charts/webapp", version = "1.2.0" }
# or
upstream = { path = "../upstream" } # resolved at build time
```

Then reference the resolved alias from `package.k`:

```kcl
# Helm chart dep — pass the resolved path to helm.template:
import charts.nginx
resources = helm.template({ chart = nginx.path, values = ... })

# KCL/Akua-package dep — once you depend on it, you can `import` it
# directly (the resolver mounts it as a KCL ExternalPkg):
import upstream
resources = upstream.resources + extras
```

`akua lock` records the resolved digest; `akua render` reads the dep from the local cache (under `~/.cache/akua/`), and the sandbox preopens that cache root in addition to the Package directory.

## What NOT to do

- **Don't pass absolute paths to plugins** (`/var/cache/...`). The sandbox refuses anything outside its preopened roots, even if you `chmod` your way to readability.
- **Don't symlink your way around it.** Akua canonicalizes plugin paths before the under-Package check; a `./link → ../upstream` symlink resolves the same as `../upstream` and gets rejected the same way.

## See also

- [`docs/lockfile-format.md`](../lockfile-format.md) — `akua.toml` `[dependencies]` syntax.
- [`docs/package-format.md`](../package-format.md) — Package authoring shape.
- [`docs/security-model.md`](../security-model.md) — sandbox invariants.