release

SwarmAI 1.0.29 Release Notes

Skill sandbox, prompt-injection defence, memory retrieval and policy correctness in SwarmAI 1.0.29.

11 min read

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.

  1. 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 word exec appearing 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.
  1. 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.
  1. Memory search actually retrieves now. Every Memory implementation matched the whole query string as a substring, so memory.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.
  1. The RL policy engine no longer degrades decisions. SelfImprovingProcess never called PolicyEngine.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.lang is not blanket-allowed (that would re-admit Runtime, ProcessBuilder, System, Thread, Class, ClassLoader). Only value and utility types are permitted individually.
  • Reflective escapes blocked: .class / .metaClass / .classLoader property access, getClass(), forName(), newInstance(), setAccessible(), method pointers (obj.&m), and dynamically-named calls (obj."${name}"()).
  • execute() is receiver-checked. Groovy's String.execute() is a shell escape, but execute() is also the documented tool-calling convention — so it is permitted only on a direct tool access: tools.name, tools['name'], or tools.get('name'). tools.http_request.execute(...) works; 'id'.execute() does not, and neither does execute() chained onto a tool's return value (tools.echo.execute(...).execute()), which would be String.execute() on attacker-influenceable tool output.
  • Compile-time AST transforms are rejected before the code is ever parsed. @groovy.transform.ASTTest runs its closure at the SEMANTIC_ANALYSIS compile phase and @Grab at CONVERSION — both before SecureASTCustomizer runs at CANONICALIZATION. So their payload executes during parse() itself, which means a hostile skill runs its code merely by being validated (@ASTTest was a confirmed arbitrary-code-execution escape this way). No SecureASTCustomizer setting 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 before parse() at every compile site (validation, execution, tests, assertions).
  • The same hardened CompilerConfiguration is 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 (no SecurityManager). 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 the CONTAINER type.

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(), and isEmpty() are not tenant-scoped — a clear() from one tenant wipes all tenants, and size() reports the global store. Fixing this cleanly requires a scoped-clear operation on the Memory SPI (a larger change than this release's scope). Until then, do not expose clear() 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.
  • InMemoryMemory is bounded. Configurable maxEntries (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 via ZREMRANGEBYRANK; clear() uses SCAN instead of the blocking KEYS; clearForAgent batches into a single ZREM instead 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 emitted BIGSERIAL, JSONB and ?::jsonb — PostgreSQL-only. Non-Postgres databases get identity columns and a TEXT metadata column. Adds a row cap with amortized retention trimming. Keyword ranking is done in SQL (a portable SUM(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. LIKE wildcards in user-derived text are escaped and every LIKE now declares ESCAPE '\' — 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:

  • LinUCB with no updates keeps A = I, b = 0, so theta = 0, every action's UCB score ties, and argmax collapses onto action 0 — the policy degenerated into "always GENERATE".
  • ThompsonSampling with no updates keeps its Beta(1,1) prior, which is uniform — so the stop/continue decision was a coin flip. It halted visibly-improving runs, and never read ConvergenceContext at 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 exactly HeuristicPolicy. 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/outputonly 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.callLlm no longer constructs a new ObjectMapper() per dynamic tool on every LLM call (the hottest path in the framework); it uses a shared, thread-safe instance.
  • InMemoryMemory search/recency queries no longer re-sort the entire store on each call.
  • RedisMemory.clearForAgent is one round-trip rather than N.
  • Agents on a host no longer retry-loop on an unwritable /app/output (see above).

Upgrade notes

ChangeImpact
Memory.searchByAgentPrefix fails closedCustom Memory implementations must override it or tenant-scoped search returns empty
Groovy sandbox is an allowlistGenerated skills using non-allowlisted packages now fail validation; use CONTAINER skills for unrestricted code
Untrusted content is fencedPrompts sent to the model change shape; prompt-snapshot tests may need updating
LearningPolicy cold-start gated on rewardsLonger heuristic phase unless recordOutcome(...) is wired; safe by default
InMemoryMemory boundedDefaults to 10,000 entries; pass new InMemoryMemory(n) to change
Output directory resolved, not hardcodedAgents 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.