SwarmAI 1.0.29 Release Notes
Skill sandbox, prompt-injection defence, memory retrieval and policy correctness in SwarmAI 1.0.29.
Released: 2026-07-12
Theme: Correctness and safety in the layers everything else sits on. Three security weaknesses closed, memory retrieval made to actually retrieve, and a policy-engine bug fixed that made the learned policy strictly worse than the heuristic it replaced.
Headline
This is a foundations release. It ships no new capability surface — it fixes things that were wrong underneath the existing one.
- The Groovy skill sandbox is now a real sandbox. It was a substring denylist, which is not a security boundary:
new java.io.File(...)needs no import,'cmd'.execute()spawns a shell without the wordexecappearing anywhere, and a method name can be assembled at runtime. All of these are now blocked by an AST-level allowlist applied at every compile site — execution, tests, assertions, and validation — so code can no longer be validated under one policy and executed under another.
- Untrusted content is fenced before it reaches the model. Retrieved memories, knowledge/RAG chunks, and prior task outputs were concatenated into prompts as raw text. A poisoned memory or a hostile web page saying "ignore your instructions and…" was read with the same authority as the operator's own instructions. These are now delimiter-fenced, with fence-escape neutralised and a matching system-prompt rule.
- Memory search actually retrieves now. Every
Memoryimplementation matched the whole query string as a substring, somemory.search(task.getDescription(), 5)only ever hit if the identical task had run before. Retrieval was near-zero while still paying to store, sort, and query. Search is now keyword-scored.
- The RL policy engine no longer degrades decisions.
SelfImprovingProcessnever calledPolicyEngine.recordOutcome(...), so the bandits received zero rewards in production — and an unfed bandit is not neutral, it is degenerate. Fixed, with a safety floor that prevents the policy from halting a run that is still improving.
Security
Groovy skill sandbox (GroovySkillSandbox) — behaviour change
The previous securityScan matched blocked substrings ("new File(", "Runtime.getRuntime", …) and the executor applied a SecureASTCustomizer that only denied import statements. Neither is a boundary. Now:
- Allowlist, not denylist.
setIndirectImportCheckEnabled(true)requires every class the code touches to be allowlisted — imported, fully-qualified, or auto-imported alike. java.langis not blanket-allowed (that would re-admitRuntime,ProcessBuilder,System,Thread,Class,ClassLoader). Only value and utility types are permitted individually.- Reflective escapes blocked:
.class/.metaClass/.classLoaderproperty access,getClass(),forName(),newInstance(),setAccessible(), method pointers (obj.&m), and dynamically-named calls (obj."${name}"()). execute()is receiver-checked. Groovy'sString.execute()is a shell escape, butexecute()is also the documented tool-calling convention — so it is permitted only on a direct tool access:tools.name,tools['name'], ortools.get('name').tools.http_request.execute(...)works;'id'.execute()does not, and neither doesexecute()chained onto a tool's return value (tools.echo.execute(...).execute()), which would beString.execute()on attacker-influenceable tool output.- Compile-time AST transforms are rejected before the code is ever parsed.
@groovy.transform.ASTTestruns its closure at theSEMANTIC_ANALYSIScompile phase and@GrabatCONVERSION— both beforeSecureASTCustomizerruns atCANONICALIZATION. So their payload executes duringparse()itself, which means a hostile skill runs its code merely by being validated (@ASTTestwas a confirmed arbitrary-code-execution escape this way). NoSecureASTCustomizersetting can stop it. Since every compile-time transform is annotation-triggered and legitimate skill code never needs annotations, untrusted skill code may now contain no annotations at all — enforced by a textual gate that runs beforeparse()at every compile site (validation, execution, tests, assertions). - The same hardened
CompilerConfigurationis now used by validation and execution, so the two can no longer disagree.
This can reject skills that previously validated. Generated code using java.util.concurrent, java.nio, other non-allowlisted packages, or any annotation will now fail the security scan. Skills needing a genuine hard boundary should use the CONTAINER skill type, which runs out-of-process.
Residual limitation, stated plainly. This is a compile-time, in-JVM sandbox on JDK 21 (noSecurityManager). It stops the reachable-API and transform-phase attacks above, but it is not an isolation boundary — resource exhaustion is not bounded by it, and a static-field read on a disallowed class (e.g.System.out) is not caught (no reachable field yields a dangerous object today, but it is a gap). Treat generated skills that process untrusted input as needing theCONTAINERtype.
Stored prompt injection (UntrustedContent) — prompt shape change
Memories, knowledge, and prior-task context are wrapped in <<, marker tokens inside the content are defanged so it cannot close its own fence, and zero-width / bidi control characters are stripped. buildSystemPrompt() gains a matching rule instructing the model to treat fenced content as data, never as instructions. Content matching instruction-override phrasings is logged (Possible prompt-injection attempt) — telemetry, not filtering.
Ordering is load-bearing: invisible characters are stripped before the markers are defanged. The reverse order is exploitable — a zero-width space hidden inside the end marker evades the defang, and the strip then reconstitutes a live end marker inside the fence, letting the content close its own fence and resume as trusted prompt. Any future normalisation step must run before the defang for the same reason (UntrustedContentTest#invisibleCharactersCannotSmuggleAMarker pins this).
This raises the cost of injection; it does not make a model immune. Keep tool permissions least-privilege and approval gates on side-effecting tools.
Tenant isolation — breaking for custom Memory implementations
Memory.searchByAgentPrefix previously defaulted to an unscoped search(...). Any custom Memory that did not override it silently leaked cross-tenant results through TenantAwareMemory. The default now fails closed: it returns an empty list and logs a warning. TenantAwareMemory also composes the caller's prefix with the tenant prefix, so a narrower scoped search stays inside the tenant.
Action required: if you ship a custom Memory, implement searchByAgentPrefix or tenant-scoped search will return nothing.
Tenant id validation — behaviour change
Tenant isolation scopes data by prefixing agent ids with . That is only injective if a tenant id cannot itself contain :: — otherwise a tenant acme (prefix acme::) matches tenant acme::sub's agent ids (acme::sub::agent1 starts with acme::) and reads its memories. TenantContext.setTenantId now rejects any tenant id containing :: (fails loud with IllegalArgumentException), and TenantAwareMemory references the single reserved-separator constant so it can't drift.
Redis stored-content spoofing — fix
RedisMemory stored content without metadata verbatim and decode() treated any {-prefixed string as a JSON envelope — so saving the literal string {"c":"x"} decoded back to x, not the original. Every stored member now carries a one-character type tag that the decoder keys on, so decoding no longer guesses from content shape and round-trips are exact. Legacy (pre-tag) entries still decode.
Known limitation (not fixed in this release):TenantAwareMemory.clear(),size(), andisEmpty()are not tenant-scoped — aclear()from one tenant wipes all tenants, andsize()reports the global store. Fixing this cleanly requires a scoped-clear operation on theMemorySPI (a larger change than this release's scope). Until then, do not exposeclear()to tenant-facing callers.
Memory subsystem
- Keyword-scored retrieval (
MemoryKeywords), shared by all implementations: the query is tokenized, stopwords dropped, and results ranked by distinct keywords matched, then recency. A memory stored as"Task: Analyze quarterly revenue figures…"is now retrieved by the differently-phrased task"Summarize the revenue trends for the last quarter"— which the old substring match never could. InMemoryMemoryis bounded. ConfigurablemaxEntries(default 10,000) with oldest-first eviction; previously it grew until OOM in a long-running process, and stored every entry twice (per-agent map + global list). Entries are now stored once and iterated newest-first instead of re-sorting the whole list on every query.RedisMemory: each sorted set is capped viaZREMRANGEBYRANK;clear()usesSCANinstead of the blockingKEYS;clearForAgentbatches into a singleZREMinstead of one round-trip per entry; metadata is stored as a JSON envelope instead of a lossy" [metadata:…]"string concat (legacy entries still read correctly).JdbcMemory: now genuinely dialect-aware. It advertised PostgreSQL/MySQL/H2 but emittedBIGSERIAL,JSONBand?::jsonb— PostgreSQL-only. Non-Postgres databases get identity columns and aTEXTmetadata column. Adds a row cap with amortized retention trimming. Keyword ranking is done in SQL (a portableSUM(CASE WHEN …)), not by scoring the newest N rows client-side — the old approach truncated by recency before ranking, so a strongly-matching older row could never surface.LIKEwildcards in user-derived text are escaped and every LIKE now declaresESCAPE '\'— without it, H2 and SQLite (which have no default LIKE escape character) treated the escaping backslash as a literal and it silently did nothing.- Prompt budgets: injected memories and knowledge now have their own character budgets (
ModelContextConfig.getMaxMemoryChars()/getMaxKnowledgeChars()). Previously they were uncapped and could crowd out the task description before the blind tail-truncation ran.
RL policy engine
SelfImprovingProcess called shouldGenerateSkill(...) and shouldStopIteration(...) but never called recordOutcome(...). The reward loop was open. An unfed bandit is not neutral:
LinUCBwith no updates keepsA = I,b = 0, sotheta = 0, every action's UCB score ties, andargmaxcollapses onto action 0 — the policy degenerated into "always GENERATE".ThompsonSamplingwith no updates keeps itsBeta(1,1)prior, which is uniform — so the stop/continue decision was a coin flip. It halted visibly-improving runs, and never readConvergenceContextat all (it was non-contextual).
Fixes:
- Cold-start is now gated on rewards received, not decisions made. A model that has never been fed a reward cannot have learned anything, so it defers to the heuristic instead of graduating into an untrained bandit.
LearningPolicy's worst case is now exactlyHeuristicPolicy.isRewardLoopOpen()surfaces the misconfiguration. - Convergence is contextual — LinUCB over
ConvergenceContext— instead of a context-blind Beta sample. - Safety floor: no reward signal may authorise stopping a run whose output is still growing. Verified against a model adversarially trained to prefer stopping: it halts a productive run 0 times in 1000.
- The reward loop is closed in
SelfImprovingProcess: skill-generation decisions are graded on whether the skill validated and how it scored; the decision to continue is graded retrospectively against the growth the next iteration produced.
Behaviour change: LearningPolicy.isColdStart() now also returns true while the models have insufficient feedback. Callers relying on cold-start ending purely on decision count will observe a longer heuristic phase — this is deliberate, and safer.
Agent output directory — behaviour change
The system prompt hardcoded /app/output/ as the place to write output files. That path only
exists inside the project's Docker images. On a host run it is not creatable, so every
file-writing agent burned its retry budget on AccessDeniedException: /app before adapting —
wasting tokens and wall-clock on every run, and making at least one regression workflow
(secure-ops) non-deterministically time out.
The directory is now resolved rather than assumed, first match winning:
1. Agent.builder().outputDirectory(...)
2. the swarmai.output.dir system property or SWARMAI_OUTPUT_DIR environment variable
3. /app/output — only when it actually exists and is writable, which keeps container
behaviour identical (both Dockerfiles now set SWARMAI_OUTPUT_DIR=/app/output explicitly)
4. ./output, relative to the working directory
The prompt names whatever actually resolves. This only decides what the model is told;FileWriteTool still enforces its own baseDirectory sandbox — the prompt is guidance, not a
permission grant.
Performance
Agent.callLlmno longer constructs anew ObjectMapper()per dynamic tool on every LLM call (the hottest path in the framework); it uses a shared, thread-safe instance.InMemoryMemorysearch/recency queries no longer re-sort the entire store on each call.RedisMemory.clearForAgentis one round-trip rather than N.- Agents on a host no longer retry-loop on an unwritable
/app/output(see above).
Upgrade notes
| Change | Impact |
|---|---|
Memory.searchByAgentPrefix fails closed | Custom Memory implementations must override it or tenant-scoped search returns empty |
| Groovy sandbox is an allowlist | Generated skills using non-allowlisted packages now fail validation; use CONTAINER skills for unrestricted code |
| Untrusted content is fenced | Prompts sent to the model change shape; prompt-snapshot tests may need updating |
LearningPolicy cold-start gated on rewards | Longer heuristic phase unless recordOutcome(...) is wired; safe by default |
InMemoryMemory bounded | Defaults to 10,000 entries; pass new InMemoryMemory(n) to change |
| Output directory resolved, not hardcoded | Agents outside Docker now write to ./output instead of failing against /app/output. Set SWARMAI_OUTPUT_DIR to pin it |
No API removals. No module changes.
Tests
1,927 core + 55 enterprise tests pass. New coverage: GeneratedSkillSandboxExecutionTest (drives real skill execution through the sandbox, asserting exploits are refused and write nothing to disk), UntrustedContentTest and AgentPromptInjectionTest (a poisoned memory store must reach the model fenced and unable to escape), keyword-retrieval / eviction / tenant-scoping tests in InMemoryMemoryTest, and adversarial sandbox cases in SkillValidatorTest.
