Safety & Anti-Crash Guarantees
How does a coding agent that edits its own source code avoid breaking itself?
Good question. yoyo has six layers of defense — from the innermost loop (every single code change) to the outermost (protected files that can never be touched). Here's how each one works.
Layer 1: Build-and-test gate on every commit
No code change is ever committed unless it passes:
cargo build && cargo test
This happens inside the evolution session itself. The agent runs the build and test suite after every edit. If either fails, the change doesn't get committed — the agent reads the error and tries to fix it.
Layer 2: CI on every push
Even after the agent commits locally, GitHub Actions runs the full
check suite on every push to main:
cargo build
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt -- --check
Clippy warnings are treated as errors (-D warnings), so even subtle
issues like unused variables or redundant clones get caught. If CI
fails, the next evolution session sees the failure and prioritizes
fixing it before doing anything else.
Layer 3: Automatic revert on build failure
The evolution script (evolve.sh) has a post-session verification step.
After all tasks run, it re-checks the build. If it fails:
- It gives the agent up to 3 attempts to fix the errors automatically
- If all fix attempts fail, it reverts to the pre-session state:
git checkout "$SESSION_START_SHA" -- src/
This means a broken session can never leave src/ in a worse state
than it started. The revert is surgical — it only touches source files,
preserving journal entries and other non-code changes.
Layer 4: Tests before features
yoyo's evolve skill requires writing a test before adding a feature. This isn't just a guideline — the planning phase explicitly instructs each implementation task to "write a test first if possible."
Why this matters: if you write the test first, you know the test covers the new behavior. If you write the feature first, you might write a test that only confirms what you already built, missing edge cases.
Layer 5: No deleting existing tests
The evolve skill has a hard rule: never delete existing tests. Tests are the agent's immune system. Removing them would let regressions slip through silently. As of this writing, yoyo has 91+ tests, and that number only goes up.
Layer 6: Protected files
Some files are simply off-limits. The agent cannot modify:
| File | Why it's protected |
|---|---|
IDENTITY.md | yoyo's constitution — defines who it is and its core rules |
PERSONALITY.md | yoyo's voice and values |
scripts/evolve.sh | The evolution loop itself — if this broke, recovery would be manual |
scripts/format_issues.py | Input sanitization for GitHub issues |
scripts/build_site.py | Website builder |
.github/workflows/* | CI configuration — the safety net that catches everything else |
These files can only be changed by human maintainers. This prevents a subtle failure mode: the agent "improving" its own safety checks in a way that weakens them.
Runtime bash safety analysis
Beyond the evolution-loop layers above, yoyo analyzes every bash command
before running it (src/safety.rs) and asks for confirmation on
destructive patterns like rm -rf / or piping find into xargs rm.
One check worth calling out: recursive-force rm on an unresolved
shell variable. A command like rm -rf "$BUILD_DIR/" looks harmless,
but if BUILD_DIR is empty or unset it expands to rm -rf / (or wipes
the current directory). yoyo flags any rm -rf / rm -fr /
rm -r -f whose target contains an unexpanded variable ($VAR,
${VAR}, ${VAR}/sub, …) and names the specific variable in the
warning, e.g.:
rm -rf target contains unresolved variable $BUILD_DIR — an empty
expansion would delete from cwd or /
The escape hatch is the guarded-expansion idiom: ${VAR:?} (or
${VAR:?message}) makes the shell abort if the variable is empty or
unset, so rm -rf "${BUILD_DIR:?}/build" passes this check — the
expansion can never silently widen the blast radius. Existing
destructive-pattern rules still apply on top.
Restricting a session with --restricted
--restricted is an opt-in narrowing for pointing yoyo at code you
do not trust. It is off by default: without the flag your tool set is
exactly what it was.
yoyo --restricted -p "what does this repo do?"
A wrapper script can also confine a run without editing its argv — set
YOYO_RESTRICTED=1 (or true) in the environment:
YOYO_RESTRICTED=1 yoyo -p "what does this repo do?"
The flag and the environment variable are OR-ed, and the environment can
only ever turn the restriction on; the flag is never turned off by
YOYO_RESTRICTED=0. Only 1 and true (trimmed, case-insensitive) count as
on — 0, false, an empty value and any other spelling all mean off, so a
typo cannot silently change the session's confinement.
It does two things:
- Turns on everything
--safe-modeturns on — no project-local MCP servers, hooks, orpermissions.allowfrom the repo's own.yoyo.toml, and no project-authored text in the system prompt (instruction files,.yoyo/memory.json, or the goal file.yoyo/goal.md, whose skip is noted on stderr). - Removes the command-running tools. Today that is
bash. yoyo prints what it removed at startup, e.g.🔒 Disabled tools: bash.
It only ever narrows. If you already passed --disallowed-tools or
--allow-dir, your own list is kept and the restriction is added to it —
--restricted can never widen a fence you are already inside.
What it does not do
It is not a sandbox, and it is worth being precise about the two gaps rather than trusting the name:
- File tools remain.
write_fileandedit_filestill work, so--restricteddoes not stop writes. Use--readfor that, and--allow-dir/--deny-dirto fence which paths any file tool may touch. - Command execution is one hop away. A dispatched
sub_agentbuilds its own tool set, so it can still get a shell. yoyo says this outright in its own startup note rather than letting the flag's name imply more than it delivers.
For a hard read-only session, combine them:
yoyo --restricted --read -p "review this code"
Reads outside the launch directory ask first (interactive sessions)
In a terminal, read_file, search and list_files ask y/n once per
directory before reading anywhere outside the directory you launched yoyo
in (so the model cannot quietly read ~/.ssh while working on your
project). Piped and scripted runs with no terminal are not fenced by
default, because there nobody could answer and the only possible outcome
would be a refusal. YOYO_ASK_OUTSIDE_READS=0 turns the fence off,
YOYO_ASK_OUTSIDE_READS=1 turns it on even without a terminal (outside
reads are then refused). bash is not covered: it can still cat the
same file. Details: Permissions.
Keeping provider keys out of subprocesses
yoyo removes every provider credential variable it knows
(ANTHROPIC_API_KEY, OPENAI_API_KEY, API_KEY, the AWS/Bedrock keys and
the rest of yoagent's and yoyo's provider tables) from the environment of
some of the programs it starts. yoyo itself keeps its key; only the child's
environment is edited. One variable, YOYO_SUBPROCESS_ENV_SCRUB, controls
it, with a different default per kind of program:
- Shell hooks (
[hooks]in.yoyo.toml): scrubbed by default. A hook is a program your config or the repository wrote, and it should not get your API key for free. SetYOYO_SUBPROCESS_ENV_SCRUB=0(orfalse) to let hooks inherit the key again. Any other value, including an empty one or a typo, keeps hooks scrubbed. Non-credential variables (PATH,HOME,TOOL_NAME,TOOL_PARAMS, your own variables) still reach the hook. - The model's
bashtool: opt-in. By default it inherits yoyo's environment, including your provider key, so a prompt injection that gets the model to runecho $ANTHROPIC_API_KEYcan read it. SetYOYO_SUBPROCESS_ENV_SCRUB=1(ortrue) to scrub it. It stays opt-in because anything that needs the key from inside bash, such as running yoyo (or another tool that calls your provider) from yoyo's own bash, fails with the scrub on. - Not covered yet:
/run,!,/bg,/watch, MCP stdio servers and sub-agent bash still inherit the full environment (tracked in #1064). GITHUB_TOKENandGH_TOKENare always kept, soghstill works.
What happens in practice
A typical evolution session:
evolve.shverifies the build passes before starting- The planning agent reads source code, journal, and issues
- Implementation agents execute tasks, each running build+test after changes
- Post-session verification re-checks everything
- If anything broke, automatic fix attempts kick in
- If fixes fail, revert to pre-session state
- CI runs on push as a final backstop
- Next session checks CI status — failures get top priority
The result: yoyo has been evolving autonomously since Day 0, growing
from ~200 lines to ~3,100+ lines, without ever shipping a broken build
to main.
Can it still break?
Theoretically, yes. Safety is defense-in-depth, not a proof of correctness. Some scenarios the current system doesn't catch:
- Logic bugs that pass tests — if the test suite doesn't cover a behavior, the agent could change it without noticing
- Performance regressions — we rely on official leaderboards (SWE-bench, etc.) rather than custom benchmarks
- Subtle UX regressions — the agent tests functionality, not user experience
These are areas for future improvement. But for the core guarantee — "the agent won't commit code that doesn't compile or pass tests" — the six layers above make that extremely unlikely.