Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,10 @@ go.work

# Local scratch files
.scratch/

# Local dev cluster state: kubeconfig, profile markers, probe results
.dev/

# Python bytecode
__pycache__/
*.pyc
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ CI pipeline) resolves application metadata before creating the CR.
- **Agent Sandbox** is a hard dependency for workload execution
- **Git credentials** stay in the harness — the agent does not receive
push credentials
- **Skills** are OCI artifacts mounted via ImageVolumes (K8s 1.33+)
- **Skills** are OCI artifacts mounted read-only via ImageVolumes (K8s 1.33+)
- **Workspaces** are ephemeral — git is the persistence layer
- **ACP over HTTP** (via `goose serve`) provides real-time observability
and human-in-the-loop interaction
Expand All @@ -56,9 +56,13 @@ agentic-controller/

## Platform requirements

- Kubernetes 1.33+ (ImageVolume GA)
- Kubernetes 1.33+ (ImageVolume beta, enabled by default)
- OpenShift 4.20+
- Agent Sandbox v0.5.x
- A container runtime that implements ImageVolume: CRI-O 1.31+ or containerd
2.0+. cri-dockerd does not implement it.

For a local cluster to develop against, see [docs/local-development.md](docs/local-development.md).
Comment on lines +59 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files =="
git ls-files | rg '(^README\.md$|hack/probe/run-probe\.sh$|docs/local-development\.md$)' || true

echo "== README relevant lines =="
if [ -f README.md ]; then
  nl -ba README.md | sed -n '50,70p'
fi

echo "== run-probe relevant lines =="
if [ -f hack/probe/run-probe.sh ]; then
  nl -ba hack/probe/run-probe.sh | sed -n '90,115p'
fi

echo "== broader ImageVolume mentions =="
rg -n "ImageVolume|containerd 2\.|containerd >= 2\.0|needs >= 2\.0|CRI-O|Kubernetes 1\.33" README.md hack/probe/run-probe.sh docs 2>/dev/null || true

Repository: konveyor/agentic-controller

Length of output: 284


🌐 Web query:

Kubernetes v1.33 beta announcement containerd ImageVolume v2.1.0

💡 Result:

In Kubernetes v1.33, released in April 2025, the ImageVolume feature graduated to beta [1][2]. This feature, introduced via KEP-4639, enables pods to mount OCI images or artifacts directly as volumes [1][3]. Regarding containerd, support for the ImageVolume feature was implemented as part of the containerd v2.1.0 release [4][1]. Specifically: - Alpha Support: Initial support for the alpha version of the ImageVolume feature was included in containerd v2.1.0 [1]. - Beta Graduation & SubPath Support: While Kubernetes v1.33 brought the ImageVolume feature to beta (enabling subPath and subPathExpr support), containerd's support for these advanced beta requirements (such as subPath support) was integrated in later developments following the initial v2.1.0 release [5][1][2]. Because the feature remains disabled by default in Kubernetes due to the requirement for full runtime support, users must explicitly enable the ImageVolume feature gate on both the API server and the kubelet [1][6]. As of the v1.33 release, users were advised to ensure their container runtime environment was updated to a version with appropriate support [1].

Citations:


🌐 Web query:

kubernetes 1.33 image volumes beta enabled by default Kubernetes v1.33

💡 Result:

In Kubernetes v1.33, the Image Volumes feature graduated to beta, but it is not enabled by default [1][2][3]. To use Image Volumes in Kubernetes v1.33, you must manually enable the ImageVolume feature gate on your API server and kubelet [4][3]. The feature remains disabled by default primarily because not all container runtimes have full support for it [1][5]. While in beta, the feature introduced support for subPath and subPathExpr in volumeMounts, allowing users to mount specific subdirectories of an image volume [1][3][5]. Additionally, new kubelet metrics were added to track usage and monitor mount success or failure [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== README relevant lines =="
if [ -f README.md ]; then
  sed -n '50,70p' README.md
fi

echo "== run-probe relevant lines =="
if [ -f hack/probe/run-probe.sh ]; then
  sed -n '90,115p' hack/probe/run-probe.sh
fi

echo "== broader ImageVolume mentions =="
rg -n "ImageVolume|containerd 2\.|containerd >= 2\.0|needs >= 2\.0|CRI-O|Kubernetes 1\.33" README.md hack/probe/run-probe.sh docs 2>/dev/null || true

Repository: konveyor/agentic-controller

Length of output: 5634


Correct the Kubernetes and containerd prerequisites for ImageVolume.

ImageVolume is beta in Kubernetes 1.33 but is not enabled by default, and containerd support is at 2.1, so both README.md#L59-L65 and the containerd warning in hack/probe/run-probe.sh#L104 are now incorrect. Update the README default-enablement wording and bump both “containerd 2.0+” references to “containerd 2.1+”.

📍 Affects 2 files
  • README.md#L59-L65 (this comment)
  • hack/probe/run-probe.sh#L103-L105
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 59 - 65, Update the ImageVolume prerequisites in
README.md at lines 59-65 to state that Kubernetes 1.33+ has ImageVolume beta
enabled but not by default, and change the containerd requirement from 2.0+ to
2.1+. Update the containerd warning in hack/probe/run-probe.sh at lines 103-105
from 2.0+ to 2.1+; no other prerequisite changes are needed.


## Related projects

Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/70-skill-exec-probe.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
kind: enhancement

description: >
Add a probe that determines, for a given cluster, whether an agent can stage
a script in a writable directory and execute it, and whether that directory
is inside the git worktree the harness commits and pushes. Records the
resulting staging convention in ADR 0007.
216 changes: 216 additions & 0 deletions docs/adr/0007-skill-script-execution-and-staging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# ADR 0007: Skill Script Execution and Staging

**Status:** Proposed
**Date:** 2026-07-28
**Authors:** Fabian von Feilitzsch

> Numbering note: 0004, 0005, and 0006 are claimed by open PRs (#47, #35, #53).

## Context

Skills are packaged as OCI images and mounted into agent pods as Kubernetes
ImageVolumes at `/opt/skills/<name>/` (`agentrun_controller.go`,
`resolveSkillVolumes`). As skills grow beyond pure prose into procedures with
concrete commands, the question arises: **can an agent execute a script that
ships with a skill?**

The concern was that skill mounts are read-only and possibly mounted `noexec`,
which would make executable payloads unusable. The concern was well-founded on
paper:

- CRI-O passes `[]string{"ro", "noexec", "nosuid", "nodev"}` to
`Store().MountImage()` in `mountImage()` — verified identical on every branch
from `release-1.31` through `main`.
- KEP-4639 dropped `noexec` as a *conformance requirement* when retargeting beta
in v1.34, but that only means runtimes are no longer obliged to apply it.
CRI-O still does.
- containerd's `container_image_mount_linux.go` shows no such flags, so the
behaviour is **runtime-dependent, not merely version-dependent**.

Two things resolved the question, one by reframing and one by measurement.

**The reframe.** Agents do not execute scripts *from* the skill mount. They
either emit the script text from `SKILL.md` into a writable directory, or copy
it off the mount — then run it from there. The skill mount is a **read** surface,
not an **exec** surface. `noexec` blocks `execve()`; it never blocks reads. So
the mount's exec semantics do not gate the approach at all. What gates it is
whether some writable directory in the pod is executable.

**The measurement.** `hack/probe/probe.sh` was run on minikube with CRI-O
1.35.0, Kubernetes v1.34.0, ImageVolume gate BETA/enabled, against both a
hand-written pod and a real controller-created Sandbox. Both agreed:

| Location | per-mount opts | superblock | noexec reaches container | write | execve |
|---|---|---|---|---|---|
| `/opt/skills/<name>` | `rw,relatime` | `ro,seclabel,...` | **no** | EROFS | allowed |
| `/tmp` | `rw,relatime` (overlay) | `rw` | no | ok | ok |
| `/workspace` | `rw,relatime` (xfs) | `rw` | no | ok | ok |

The skill mount line, verbatim:

```
1267 1259 0:180 / /opt/skills/probe-skill rw,relatime - overlay overlay ro,seclabel,lowerdir=...
```

**`noexec` does not reach the container even under CRI-O.** CRI-O applies it to
the lowerdir image-store mount, then layers an `overlay` on top; overlayfs does
not inherit `MS_NOEXEC` from its lower mount. Read-only *is* enforced, but at the
superblock rather than in the per-mount flags — which is why writes fail with
EROFS while the per-mount options read `rw`.

Independently measured kernel semantics (ubi9-minimal, a real `noexec` tmpfs)
that hold wherever `noexec` *is* applied: `./x.sh` fails, while `sh x.sh`,
`bash x.sh`, `sh < x.sh`, `. x.sh` and `python3 x.py` all succeed — an
interpreter only reads the file. Compiled binaries fail. `chmod` on a read-only
mount fails with EROFS regardless.

## Decision

**1. Skills are a read surface. Agents stage scripts before executing them.**

Executing directly from `/opt/skills/` is not a supported pattern, even though it
currently happens to work under CRI-O. Relying on it would couple skill authoring
to a container-runtime implementation detail that KEP-4639 explicitly stopped
requiring, and that containerd and CRI-O already disagree about.

**2. The staging directory is `/tmp`, not `/workspace`.**

`/workspace` is the git worktree. The harness commits and force-pushes it, and
the filesystem watcher introduced in PR #53 auto-commits during a run — so a
script staged there can land on the user's branch. `/tmp` is writable,
executable, and outside the worktree. Both were measured exec-capable; the
difference is the commit risk, not the capability.

**3. Skill authoring guidance must state this explicitly.**

This is **measured, not predicted**. A real run — claude-sonnet-5 via goose, the
PR #53 harness, Hub-resolved application, coolstore cloned to `/workspace/repo` —
was given a skill that said only:

> 1. Write the verification script above to a file.
> 2. Make the file executable.
> 3. Run it.

with no mention of location. The model did:

```
tool: write · /workspace/repo/verify.sh
tool: shell · chmod +x /workspace/repo/verify.sh && /workspace/repo/verify.sh
```

It wrote **into the git worktree**. `/tmp` was left empty. Execution succeeded,
confirming again that the exec surface is not the constraint — placement is.

Left unsaid, the wrong behaviour is the default behaviour. The convention has to
be stated in the skill text itself; it will not be inferred.

**3b. The rule belongs in the harness, not in each skill or Agent prompt.**

Where the rule lives matters as much as its content. It is a property of the
execution environment — the working directory is a git worktree that gets pushed
— not of any particular skill, so requiring every SkillCard author (or every
Agent author) to restate it is how it gets forgotten. The harness injects it into
every prompt as a `## Working Environment` preamble in `buildPrompt`, ahead of
the Agent prompt, the playbook context, the skill, and the stage task.

**Verified.** Rerunning the identical experiment — same skill, same silent
instructions, same model, only the harness changed:

```
before: tool: write · /workspace/repo/verify.sh
tool: shell · chmod +x /workspace/repo/verify.sh && /workspace/repo/verify.sh

after: tool: shell · cat > /tmp/verify.sh << 'EOF'
```

Zero writes into the repository. The proposed patch is 22 lines in
`harness/cmd/migration-harness/main.go` and applies to PR #53.

**3a. The current safety net is incidental, and should not be relied on.**

The staged `verify.sh` was *not* committed — but only because the filesystem
watcher's `ShouldStageNewFile` allowlists by extension, and `.sh` happens not to
be listed. The base set is `.md .json .yaml .yml .xml .properties .txt`, and the
Java image adds `.java .gradle .kts .kt .groovy`.

So a stray `.sh` is safe by accident, while a stray `.md`, `.json`, `.yaml` or
`.txt` scratch file **would** be auto-committed and pushed to the user's branch —
and those are exactly the extensions an agent is most likely to use for notes,
plans, and intermediate output. The allowlist is protecting the wrong things by
coincidence. Staging outside the worktree is the actual fix.

For payloads too large to inline in `SKILL.md`, ship the file in the skill and
copy it out — reading from the mount always works:

```sh
cp /opt/skills/<name>/helper.sh /tmp/ && chmod +x /tmp/helper.sh && /tmp/helper.sh
```

**4. Skills do not ship compiled binaries.**

Binaries cannot run from a `noexec` mount at all, must be built per
architecture, and would make skills non-portable across the runtimes we intend to
support. Tools belong in the agent image, which is where the air-gap and image
composition story already lives (ADR 0001).

## Alternatives Considered

**Bake the executable bit into the OCI layer and execute in place.** Works today
under CRI-O, and `skillctl` already preserves modes via `tar.FileInfoHeader`
(`pkg/oci/build.go`). Rejected: it depends on `noexec` not being applied, which
is a runtime implementation detail rather than a guarantee. A hardened cluster,
a policy layer such as OpenShell (PR #47, which explicitly covers "binary
restrictions"), or a future CRI-O change would break every such skill at once.

**Have the harness stage `/opt/skills/*/scripts/` into a writable exec directory
at startup.** Robust, and it would make the agent's natural `./foo.sh` reflex
work. Rejected as unnecessary: agents already write scripts themselves, so this
adds a harness mechanism to solve a problem that does not exist. Worth revisiting
only if skills ever need to ship executable payloads.

**Mount skills exec-capable.** Not available: `ImageVolumeSource` exposes only
`reference` and `pullPolicy`. There is no mount-options knob.

## Consequences

- Skill authors write scripts as *content*, and the invocation convention is
staging to `/tmp`. This must appear in authoring documentation and ideally in
the stage skills themselves.
- The platform is insensitive to whether a given cluster applies `noexec` to
image volumes, which removes a runtime-dependent variable from the support
matrix.
- `hack/probe/run-probe.sh` re-answers the question on any cluster. It should be re-run
against real OpenShift before dev preview, and whenever the target platform
version moves — the finding here is from minikube/CRI-O 1.35.0 and is evidence,
not a guarantee.
- The only outcome that would invalidate this decision is
`BLOCKED_NO_EXEC_SURFACE`: a cluster where nothing writable is executable.
Not observed, but plausible on hardened clusters that mount emptyDir `noexec`.

## Appendix: the injected rules

Verified text, added to `buildPrompt` in the harness ahead of every other
context layer. Reproduced here so the decision survives independently of any
particular branch.

```go
const stagingRules = `## Working Environment

Your working directory is a git repository. When this stage finishes it is
committed and pushed to the user's branch, so anything you leave in it ships to
the user.

Write ephemeral files to /tmp, never into the repository working tree:
- scripts you need to run: write them to /tmp, make them executable there,
and run them from there
- scratch notes, plans, logs, and intermediate output

Only files the user actually asked for belong in the repository. This applies
even when a skill's instructions do not say where to put something.
`
```

## References

- `hack/probe/probe.sh`, `hack/probe/run-probe.sh` — the probe
- KEP-4639 (OCI VolumeSource); CRI-O `server/container_create_linux.go`
45 changes: 45 additions & 0 deletions hack/probe/probe-pod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
# Standalone probe pod for the staging-directory execution question.
#
# Deliberately depends on NOTHING: no CRDs, no controller, no Agent Sandbox, no
# LLM. It reproduces the shape of a controller-created sandbox pod -- same agent
# base image, an ImageVolume-mounted skill at /opt/skills/<name>, an emptyDir
# workspace -- so its mount semantics are directly comparable, while being
# answerable on a bare cluster in about 30 seconds.
#
# The probe itself is NOT baked in. It is piped in at run time:
# kubectl exec -i imagevolume-probe -- sh -s < hack/probe/probe.sh
# so iterating on the probe never requires rebuilding or reloading an image.
#
# Image placeholders are substituted by hack/probe/run-probe.sh. Applying this
# file directly uses the defaults below.
apiVersion: v1
kind: Pod
metadata:
name: imagevolume-probe
labels:
app.kubernetes.io/name: imagevolume-probe
app.kubernetes.io/component: diagnostic
spec:
restartPolicy: Never
# Override the stub entrypoint: we want a bare long-running process, not the
# stub's banner logic, so the probe measures the environment and nothing else.
containers:
- name: agent
image: quay.io/konveyor/agentic-controller-agent:e2e
command: ["sleep", "infinity"]
volumeMounts:
- name: skill
mountPath: /opt/skills/probe-skill
readOnly: true
- name: workspace
mountPath: /workspace
volumes:
# The mount under test. Any skill image works -- we are measuring how the
# kubelet/CRI mounts an ImageVolume, not the contents.
- name: skill
image:
reference: quay.io/konveyor/skills:maven-migration
# Matches the controller's workspace volume (agentrun_controller.go:332).
- name: workspace
emptyDir: {}
Comment on lines +16 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Add restricted-compatible securityContext so the probe can schedule on hardened/PSA-restricted clusters.

No securityContext is set, so this pod will fail admission on any namespace enforcing the Kubernetes "restricted" Pod Security Standard — exactly the kind of hardened cluster ADR 0007 calls out as the one scenario ("BLOCKED_NO_EXEC_SURFACE") that would need re-testing.

🔒 Suggested fix
 spec:
   restartPolicy: Never
+  securityContext:
+    runAsNonRoot: true
+    seccompProfile:
+      type: RuntimeDefault
   containers:
     - name: agent
       image: quay.io/konveyor/agentic-controller-agent:e2e
       command: ["sleep", "infinity"]
+      securityContext:
+        allowPrivilegeEscalation: false
+        capabilities:
+          drop: ["ALL"]
       volumeMounts:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
apiVersion: v1
kind: Pod
metadata:
name: imagevolume-probe
labels:
app.kubernetes.io/name: imagevolume-probe
app.kubernetes.io/component: diagnostic
spec:
restartPolicy: Never
# Override the stub entrypoint: we want a bare long-running process, not the
# stub's banner logic, so the probe measures the environment and nothing else.
containers:
- name: agent
image: quay.io/konveyor/agentic-controller-agent:e2e
command: ["sleep", "infinity"]
volumeMounts:
- name: skill
mountPath: /opt/skills/probe-skill
readOnly: true
- name: workspace
mountPath: /workspace
volumes:
# The mount under test. Any skill image works -- we are measuring how the
# kubelet/CRI mounts an ImageVolume, not the contents.
- name: skill
image:
reference: quay.io/konveyor/skills:maven-migration
# Matches the controller's workspace volume (agentrun_controller.go:332).
- name: workspace
emptyDir: {}
apiVersion: v1
kind: Pod
metadata:
name: imagevolume-probe
labels:
app.kubernetes.io/name: imagevolume-probe
app.kubernetes.io/component: diagnostic
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
# Override the stub entrypoint: we want a bare long-running process, not the
# stub's banner logic, so the probe measures the environment and nothing else.
containers:
- name: agent
image: quay.io/konveyor/agentic-controller-agent:e2e
command: ["sleep", "infinity"]
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: skill
mountPath: /opt/skills/probe-skill
readOnly: true
- name: workspace
mountPath: /workspace
volumes:
# The mount under test. Any skill image works -- we are measuring how the
# kubelet/CRI mounts an ImageVolume, not the contents.
- name: skill
image:
reference: quay.io/konveyor/skills:maven-migration
# Matches the controller's workspace volume (agentrun_controller.go:332).
- name: workspace
emptyDir: {}
🧰 Tools
🪛 Checkov (3.3.8)

[medium] 16-45: Containers should not run with allowPrivilegeEscalation

(CKV_K8S_20)


[medium] 16-45: Minimize the admission of root containers

(CKV_K8S_23)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/probe/probe-pod.yaml` around lines 16 - 45, Add a pod-level
securityContext to the imagevolume-probe Pod spec that satisfies Kubernetes
restricted Pod Security requirements, including non-root execution, seccomp
RuntimeDefault, and dropping all Linux capabilities. Keep the existing container
command, mounts, and volumes unchanged.

Source: Linters/SAST tools

Loading
Loading