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
104 changes: 63 additions & 41 deletions .github/workflows/aeon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,25 @@ jobs:
# be staged here just like the other adapter harnesses. It reuses
# `run-grok.sh setup` — the single source of truth for grok's pin and the
# GROK_CREDENTIALS/XAI_API_KEY restore — so nothing is duplicated.
# bubblewrap is what harness-adapter/lib/sandbox.sh uses to make read-only
# mean "the workspace physically cannot be written" on Linux. This used to
# live inside "Install harness CLI", which skips claude entirely and skipped
# grok by name — so those two had no sandbox available even when asked for
# one, and run-harness would have degraded to "read-only is advisory" with
# only a stderr notice. Now every harness gets it, so --mode read-only means
# the same thing on all six.
- name: Install bubblewrap (read-only sandbox)
if: steps.work.outputs.mode != ''
run: |
set -euo pipefail
sudo apt-get update -qq && sudo apt-get install -y -qq bubblewrap
# Ubuntu 24.04 (including GitHub's ubuntu-latest) restricts unprivileged
# user namespaces via AppArmor, so bwrap dies with 'setting up uid map:
# Permission denied' and run-harness fails CLOSED on every read-only
# skill. Measured on this runner 2026-07-21. Re-enable them for the job.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
bwrap --dev-bind / / --ro-bind /tmp /tmp true && echo 'bwrap: userns OK' || echo '::warning::bwrap still blocked'

- name: Install harness CLI
if: steps.harness.outputs.HARNESS != '' && steps.harness.outputs.HARNESS != 'claude'
env:
Expand Down Expand Up @@ -375,21 +394,6 @@ jobs:
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
run: |
set -euo pipefail
# bubblewrap is what harness-adapter/lib/sandbox.sh uses to make
# read-only mean "the workspace physically cannot be written" on Linux.
# grok is skipped: like claude it runs through run-harness with
# --no-sandbox (its own bypassPermissions + the post-run stray-write
# revert are its read-only guard), so it never invokes the wrapper.
if [ "$H" != "grok" ]; then
sudo apt-get update -qq && sudo apt-get install -y -qq bubblewrap
# Ubuntu 24.04 (including GitHub's ubuntu-latest) restricts unprivileged
# user namespaces via AppArmor, so bwrap dies with 'setting up uid map:
# Permission denied' and run-harness fails CLOSED on every read-only
# skill. Measured on this runner 2026-07-21. Re-enable them for the job.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
bwrap --dev-bind / / --ro-bind /tmp /tmp true && echo 'bwrap: userns OK' || echo '::warning::bwrap still blocked'
fi

# Each harness is configured for the provider AUTH_MODE selected:
# native-oauth — restore the captured login (~/.codex, ~/.kimi-code);
# the harness then runs on its own default model.
Expand Down Expand Up @@ -839,12 +843,20 @@ jobs:
echo "GATEWAY=${CANDIDATES%% *}" >> "$GITHUB_OUTPUT" # provisional; overwritten with the winner below
fi

# Generate notify script in workspace (Claude calls: ./notify "message")
# Messages are saved to .pending-notify/ for reliable post-run delivery
# (sandbox may block outbound curl during Claude's run)
# Pre-create .pending-notify/ so the sandbox doesn't block Claude's first write into it
mkdir -p .pending-notify
: > .notify-sent-hashes
# Generate notify script in workspace (the agent calls: ./notify "message").
# Undelivered messages queue under $AEON_PENDING_DIR for post-run re-delivery.
# notify's staging area, OUTSIDE the workspace. A read-only skill runs
# under an OS sandbox that mounts the repo read-only, so queues kept in
# the workspace can't be written; and the json-render staging file was
# being committed (.gitignore ignores `.pending-*/` dirs, not
# `.pending-*.md`), which let a sandboxed run republish the PREVIOUS
# run's digest as its own. Exported to $GITHUB_ENV so the post-run
# capture / feed / re-delivery steps read from the same place.
AEON_PENDING_DIR="${RUNNER_TEMP:-/tmp}/aeon-pending"
mkdir -p "$AEON_PENDING_DIR/notify-queue"
: > "$AEON_PENDING_DIR/notify-sent-hashes"
echo "AEON_PENDING_DIR=$AEON_PENDING_DIR" >> "$GITHUB_ENV"
export AEON_PENDING_DIR

# notify / notify-jsonrender are the committed scripts/*.sh (testable; see scripts/tests/).
cp scripts/notify.sh ./notify
Expand Down Expand Up @@ -972,14 +984,17 @@ jobs:
# There is no longer a native `claude -p` or `run-grok.sh` run path; the
# two features those paths carried are preserved AROUND the adapter call:
# * claude — wrapped in the multi-provider gateway cascade below, so it
# keeps 8-provider failover + Langfuse tracing. --no-sandbox:
# claude's own --allowedTools + the post-run stray-write
# revert are its read-only guard, and it keeps .pending-notify
# (inside the workspace) writable.
# * grok — GROK_* run-knobs from frontmatter (adapters/grok.sh maps
# them); --no-sandbox for the same reason (bypassPermissions
# + revert are its guard; its own --sandbox is a 0.2.101 no-op).
# * codex/pi/vibe/kimi — the wrapper OS sandbox (bwrap) enforces read-only.
# keeps 8-provider failover + Langfuse tracing.
# * grok — GROK_* run-knobs from frontmatter (adapters/grok.sh maps them).
# ALL six now run read-only under the wrapper OS sandbox (bwrap). claude and
# grok used to pass --no-sandbox because ./notify needed to write its queue
# inside the workspace; that queue moved to $AEON_PENDING_DIR, so the reason
# is gone. It mattered most on grok, whose adapter runs
# --permission-mode bypassPermissions and never applied an allowlist: with
# the sandbox off, a read-only grok skill had NO runtime enforcement at all
# and could write anywhere in the repo (measured — it created a file on
# request). The post-run guard reverted it after the fact; now the write is
# refused outright.
CLAUDE_OK=0
RH="${GITHUB_WORKSPACE}/harness-adapter/run-harness"
RH_ARGS=(--mode "$SKILL_MODE" --allowed-tools "$ALLOWED" --timeout 900)
Expand All @@ -1005,7 +1020,7 @@ jobs:
MODEL="$BASE_MODEL"
source "$GW" >&2
source "$LF" >&2 # optional Langfuse tracing (no-op unless LANGFUSE_* set)
echo "$PROMPT" | bash "$RH" claude --model "$MODEL" --no-sandbox "${RH_ARGS[@]}" 2>/tmp/harness-stderr.txt
echo "$PROMPT" | bash "$RH" claude --model "$MODEL" "${RH_ARGS[@]}" 2>/tmp/harness-stderr.txt
); then
USED_GATEWAY="$cand"; CLAUDE_OK=1; break
fi
Expand All @@ -1027,7 +1042,6 @@ jobs:
# consumed by adapters/grok.sh — the same frontmatter mapping the
# native path used. SKILL_MODEL was already set to grok's model above.
eval "$(bash "${GITHUB_WORKSPACE}/scripts/skill_mode.sh" grok-run-env "$SKILL_NAME")"
RH_ARGS+=(--no-sandbox)
# BASE_MODEL is grok-4.5 or an explicit grok id (forced above); pass
# only a real grok id — the adapter falls back to grok's default
# otherwise. (BASE_MODEL, not MODEL: identical here, but MODEL is the
Expand Down Expand Up @@ -1208,7 +1222,7 @@ jobs:
SKILL="${{ steps.skill.outputs.name }}"
mkdir -p output/.chains
# Save the notification message as a chain artifact
PENDING="apps/dashboard/outputs/.pending-${SKILL}.md"
PENDING="${AEON_PENDING_DIR:-${RUNNER_TEMP:-/tmp}/aeon-pending}/.pending-${SKILL}.md"
if [ -f "$PENDING" ]; then
cp "$PENDING" "output/.chains/${SKILL}.md"
elif [ -f /tmp/skill-result.txt ] && [ -s /tmp/skill-result.txt ]; then
Expand Down Expand Up @@ -1250,7 +1264,7 @@ jobs:
# json-render feed (the trust boundary that actually benefits).
PUBLISHED=false
if [ "${JSONRENDER_ENABLED}" = "true" ] \
&& [ -f "apps/dashboard/outputs/.pending-${SKILL}.md" ]; then
&& [ -f "${AEON_PENDING_DIR:-${RUNNER_TEMP:-/tmp}/aeon-pending}/.pending-${SKILL}.md" ]; then
PUBLISHED=true
fi

Expand Down Expand Up @@ -1556,7 +1570,7 @@ jobs:
GATEWAY="${{ steps.run.outputs.GATEWAY }}"
source "${GITHUB_WORKSPACE}/scripts/llm-gateway.sh"
AEON_OTEL_COMPONENT=feed source "${GITHUB_WORKSPACE}/scripts/langfuse-otel.sh"
for pending in apps/dashboard/outputs/.pending-*.md; do
for pending in "${AEON_PENDING_DIR:-${RUNNER_TEMP:-/tmp}/aeon-pending}"/.pending-*.md; do
[ -f "$pending" ] || continue
SKILL=$(basename "$pending" .md | sed 's/^\.pending-//')
echo "json-render: converting $SKILL..."
Expand Down Expand Up @@ -1592,16 +1606,17 @@ jobs:
NOTIFY_EMAIL_SUBJECT_PREFIX: ${{ vars.NOTIFY_EMAIL_SUBJECT_PREFIX || '[Aeon]' }}
SKILL_NAME: ${{ steps.skill.outputs.name }}
run: |
# Re-deliver any .pending-notify/ messages that failed during sandbox.
# Re-deliver any queued messages that failed to send during the run.
Q="${AEON_PENDING_DIR:-${RUNNER_TEMP:-/tmp}/aeon-pending}/notify-queue"
# Skip messages whose hash was already sent by ./notify inline,
# and skip short test/trace probes that shouldn't reach channels.
if [ ! -d .pending-notify ] || [ -z "$(ls -A .pending-notify/*.md 2>/dev/null)" ]; then
if [ ! -d "$Q" ] || [ -z "$(ls -A "$Q"/*.md 2>/dev/null)" ]; then
echo "No pending notifications"
exit 0
fi
HASH_FILE=".notify-sent-hashes"
HASH_FILE="${AEON_PENDING_DIR:-${RUNNER_TEMP:-/tmp}/aeon-pending}/notify-sent-hashes"
touch "$HASH_FILE"
for pending in .pending-notify/*.md; do
for pending in "$Q"/*.md; do
[ -f "$pending" ] || continue
MSG=$(cat "$pending")

Expand Down Expand Up @@ -1660,7 +1675,7 @@ jobs:
'{from:$from, to:[$to], subject:$subject, html:$html, text:$text}')" > /dev/null 2>&1 || true
fi
done
rm -rf .pending-notify
rm -rf "$Q"

- name: Read-only capability guard
if: steps.work.outputs.mode != '' && env.SKILL_MODE == 'read-only'
Expand All @@ -1675,7 +1690,14 @@ jobs:
printf '\n## %s (read-only)\nRan %s UTC.\n' "$SKILL" "$(date -u +%FT%TZ)" >> "memory/logs/${TODAY}.md"
CODE_PATHS="skills .github apps/mcp-server apps/webhook apps/dashboard/app apps/dashboard/lib apps/dashboard/components scripts docs catalog bin *.md *.json *.yml aeon"
git checkout -- $CODE_PATHS 2>/dev/null || true
git clean -fd -- skills scripts docs .github bin catalog 2>/dev/null || true
# Same path set as CODE_PATHS above. `git checkout` only reverts EDITS to
# TRACKED files - it cannot remove a file the run CREATED. The clean list
# used to cover 6 of those paths, so a new untracked file elsewhere (e.g.
# apps/dashboard/lib/x.ts, or a brand-new top-level dir) survived the guard
# and was committed by `git add -A` below. No -x, so gitignored build
# artifacts (node_modules, .next) are untouched; memory/ and output/ are in
# neither list and still commit by design.
git clean -fd -- $CODE_PATHS 2>/dev/null || true
echo "read-only guard: logged run + reverted code/config writes for $SKILL"

- name: Commit results
Expand All @@ -1685,7 +1707,7 @@ jobs:
run: |
git config user.name "aeonframework"
git config user.email "aeonframework@proton.me"
rm -f ./notify ./notify-jsonrender .notify-sent-hashes # Remove generated notify scripts + dedup log before committing
rm -f ./notify ./notify-jsonrender # Remove generated notify scripts before committing

# OKF: stamp `type:` frontmatter onto any in-scope markdown the run
# created bare (e.g. a fresh memory/logs/ day-file appended via bash by
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
# .pending-notify (notify re-delivery), .pending-deploy (deploy-prototype build),
# plus .pending-notify-temp where skills stage notify bodies for `./notify -f`
.pending-*/
# ...and the FILE form. The trailing slash above matches directories only, so the
# json-render staging file (.pending-<skill>.md) was tracked and committed; a later
# read-only run that could not overwrite it had the previous run's digest published
# as its own. It now lives outside the repo ($AEON_PENDING_DIR); this is the guard
# against it coming back.
.pending-*.md
apps/dashboard/outputs/.pending-*.md

# Skill provenance lock (per-repo, not committed)
skills.lock
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Two exemptions: **reserved `index.md`/`log.md`** stay untyped (OKF §6/§7), and
- **`./scripts/skill-runs [--hours N] [--full] [--json] [--failures]`** — Audit recent GitHub Actions skill runs (counts, pass/fail rates, anomalies). Needs `gh` + `jq`.
- **WebSearch** / **WebFetch** — built-in Claude tools for search and URL fetching; they bypass the bash sandbox, so prefer them over `curl` for reads.

**json-render feed:** when `JSONRENDER_ENABLED=true` **and** `SKILL_NAME` is set, `./notify` queues your output at `apps/dashboard/outputs/.pending-${SKILL_NAME}.md`; a post-run workflow step then converts it into a rendered spec via `./notify-jsonrender`, which the dashboard feed displays. (`./aeon` itself only launches the dashboard web app — it does not run skills.)
**json-render feed:** when `JSONRENDER_ENABLED=true` **and** `SKILL_NAME` is set, `./notify` queues your output at `$AEON_PENDING_DIR/.pending-${SKILL_NAME}.md` (outside the repo); a post-run workflow step then converts it into a rendered spec via `./notify-jsonrender`, which the dashboard feed displays. (`./aeon` itself only launches the dashboard web app — it does not run skills.)

## Capability mode

Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ from or pin to; the template keeps serving the latest `main` to new forks.

### Fixed

- **Read-only skills are now sandboxed on every harness, and notify's queues moved
out of the repo.** Three linked defects:
- **`.pending-<skill>.md` was being committed.** `.gitignore` ignores
`.pending-*/` (directories), not the file, so the json-render staging file was
tracked. A sandboxed harness that could not overwrite it left the previous
run's copy in place and "Capture skill output" published *that* as the new
run's output — a green run reporting another run's work, and feeding it to the
health scorer. Measured on a live instance: four consecutive runs captured a
byte-identical artifact from a fifth.
- **notify's queues lived in the workspace**, so under the OS sandbox they could
not be written at all — costing dedup state, the re-delivery queue and the feed
entry. They now live in `$AEON_PENDING_DIR` (outside the repo, exported by the
workflow), which is also what removed the reason `claude`/`grok` ran with
`--no-sandbox`.
- **`claude` and `grok` therefore ran read-only skills with no runtime
enforcement.** It mattered most on grok, whose adapter uses
`--permission-mode bypassPermissions` and never applied an allowlist: a
read-only grok skill could write anywhere in the repo (verified — it created a
file on request), with only the post-run guard reverting it afterwards. Both
now run under the wrapper sandbox. Bubblewrap installation was hoisted out of
"Install harness CLI" — which skips claude and skipped grok by name — into its
own step, or the sandbox would have silently degraded to advisory.
- **The read-only guard now cleans what it reverts.** `git checkout` only undoes
edits to *tracked* files; the `git clean` list covered 6 of the ~15 `CODE_PATHS`,
so a file a read-only skill *created* outside those 6 (e.g. under
`apps/dashboard/lib/`, or a new top-level directory) survived and was committed
by `git add -A`. Both lists are now the same set.
- **`scripts/run-grok.sh` is now setup-only.** With every surface dispatching
through `run-harness`, the script's ~260-line run path (model/permission flags,
MCP allows, run-shaping knobs, grok invocation, envelope normalization) had no
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Two exemptions: **reserved `index.md`/`log.md`** stay untyped (OKF §6/§7), and
- **`./scripts/skill-runs [--hours N] [--full] [--json] [--failures]`** — Audit recent GitHub Actions skill runs (counts, pass/fail rates, anomalies). Needs `gh` + `jq`.
- **WebSearch** / **WebFetch** — built-in Claude tools for search and URL fetching; they bypass the bash sandbox, so prefer them over `curl` for reads.

**json-render feed:** when `JSONRENDER_ENABLED=true` **and** `SKILL_NAME` is set, `./notify` queues your output at `apps/dashboard/outputs/.pending-${SKILL_NAME}.md`; a post-run workflow step then converts it into a rendered spec via `./notify-jsonrender`, which the dashboard feed displays. (`./aeon` itself only launches the dashboard web app — it does not run skills.)
**json-render feed:** when `JSONRENDER_ENABLED=true` **and** `SKILL_NAME` is set, `./notify` queues your output at `$AEON_PENDING_DIR/.pending-${SKILL_NAME}.md` (outside the repo); a post-run workflow step then converts it into a rendered spec via `./notify-jsonrender`, which the dashboard feed displays. (`./aeon` itself only launches the dashboard web app — it does not run skills.)

## Capability mode

Expand Down
2 changes: 1 addition & 1 deletion docs/attestation.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ in `aeon.yml` always wins.
With the global switch on but no per-skill override, the **default policy attests
only runs whose output crossed the trust boundary** — i.e. runs that
**published to the json-render feed**. Concretely, the gate checks for
`apps/dashboard/outputs/.pending-${SKILL}.md`, which is written **only when a
`$AEON_PENDING_DIR/.pending-${SKILL}.md` (outside the repo), which is written **only when a
skill actually calls `./notify`** (with `JSONRENDER_ENABLED` on, its default).

**This is deliberate.** Each attestation is a Sigstore signing plus a permanent,
Expand Down
Loading