michal78/laravel-pandora
An agentic framework for Laravel: build agents, tools, approvals, automations and memory under explicit, auditable policy, with a Livewire control center for operating them.
This release carries two security fixes. Untrusted content could close its own delimiter and
continue outside it in a system message — a memory is the one that matters, because it persists —
and read_config could publish an allowlisted credential-shaped key into a tool result. Both are
detailed below. Hosts should upgrade; no host action is required beyond doing so.
Phase 9's threat audit continues. First T1, T4, T10 and T11, chosen because all four sit where a fake stands in for a real boundary; then T12 and T14, chosen because a concurrency test that never actually contends passes trivially and nothing about reading it says so; then T2, expected to be the dull one. T10 and most of T12 audited clean, which is a result rather than an absence of one.
The T12/T14 findings are all in the suite rather than in shipped behaviour — no security fix was needed — and the root cause of four of the five is that the suite runs serially, so every row lock in the codebase was untestable and therefore untested.
T2's are the same: no shipped behaviour was wrong, and both findings are about what the suite could
not see. Tenancy has more tests than any other threat in the model and all three of its controls held
under removal — but nothing checked that any given model had opted into the scope, and nothing
checked that a queued job re-enters the tenant it carries. The second one shares a root cause with
T12/T14: jobs run inline under QUEUE_CONNECTION=sync, so the dispatching process hands a job the
context a real worker would have had to re-establish for itself.
Untrusted content could close its own delimiter. <file>, <memory> and <environment>
interpolated their content straight between the markers, so a document, a remembered note or an
agent's name containing </file> — or </memory>, or </environment> — closed the region and
continued outside it, in a system message. A delimiter the content can write is not a
delimiter.
Memory is the one that matters, because it persists. A memory is written by the remember tool,
which is driven by model output, which may be reading an attacker's page: store one crafted note
and every later run in that scope carried it, in the instruction region, past a marker it had
closed. UntrustedBlock now neutralises every closing marker for its own tag, case- and
whitespace-insensitively, and neutralises rather than strips, so a style guide containing
</file> in a code sample still reads correctly.
Behaviour change: attached context files and retrieved memories now reach the model as user
messages rather than system ones. Both are UNTRUSTED in the trust boundary — the same line as a
web page and a channel message — and every other untrusted string in the system is already kept
out of the instruction position. No host action is needed; if you depend on the exact prompt
layout, the section order is unchanged.
read_config refuses credential-shaped keys even when allowlisted. The allowlist is exact-match
and never a prefix, which was already the right design, but an exact allowlist is still a person's
judgement and services.stripe.secret is a key somebody could reasonably add while wiring up a
tool. One config line and a live credential became a tool result, read by a model and stored in an
execution row the redactor would not clean, because the key it is filed under is value. Refused
on the key, before the value is read. The refusal names the config entry to remove.
The denylist is a hard-coded baseline plus whatever the deployment adds, not the redaction
list alone. pandora.security.redact_keys is tuned for output noise — an operator dropping
session because it clutters every trace is making a reasonable call about logs — and deriving
the refusal from it meant a change made for one purpose silently weakened another. Narrowing
redact_keys now narrows redaction and cannot touch this.
pandora:tool:list no longer prints required for tools that need no approval.
RiskLevel::requiresApprovalByDefault() hard-coded high || critical while the gatekeeper reads
pandora.approvals.required_for. Both agreed on the shipped defaults, so a deployment that
narrowed the floor to critical got a console table saying a high-risk tool required a human when
it did not. Found by deleting a mitigation and watching nothing fail — the method was never on the
enforcement path.The MySQL leg of the matrix runs in a fraction of the time. It took 604 seconds against MariaDB's 232 on identical tests, and the cause was the test harness meeting MySQL 8's DDL path. With no wrapping transaction available — Pandora catches unique-key violations as normal control flow, and on PostgreSQL a failed statement poisons the enclosing transaction — the harness empties every table between tests: ~35 tables times ~1,830 tests, or roughly 64,000 statements per run.
TRUNCATE is DDL. InnoDB drops and recreates the tablespace, MySQL 8 routes that through its
transactional data dictionary, and it fsyncs, because MySQL 8 ships the binary log on where
MariaDB ships it off (verified: mysql:8.4 reports log_bin=1, mariadb:11 reports 0). The
cleanup is now DELETE, which is ordinary DML — no tablespace churn, no dictionary write, no
implicit commit — and it is faster on every engine rather than only the slow one.
Measured on a real MySQL 8.4: the same 260 tests went from 395.77s to 31.34s.
DELETE does not reset AUTO_INCREMENT, and nothing here depends on that — Pandora's keys are
ULIDs, and no test asserts an id value against the two auto-increment tables in play.
The table listing is cached for the life of the process. Schema::getTables() ran once per
test, which on MySQL 8 is a data-dictionary query, to answer a question whose answer changes only
when the migrations do. Invalidated in the one place the schema is rebuilt.
Security/ExactlyOnceUnderLockTest (T14) — the two row locks that carry "exactly once", which
nothing had ever exercised. Deleting lockForUpdate() from ApprovalManager::resolve() left all
1,809 tests green, and deleting it from ExecuteToolCall::fanIn() did too: a lock does nothing in
a serial test and cannot fail one. The new tests open a second connection to the same database and
hold a real row lock on it. They skip on SQLite, where lockForUpdate() compiles to nothing
because SQLite has no row locking, and run on the four server-engine legs of the matrix.ExecuteToolCall without the tenant and actor a real at-least-once redelivery carries, so the
actor came back null and the tool's own authorize() refused the second call — they were proving
that a job stripped of its actor is denied. Every guard in ExecuteToolCall could be deleted and
they stayed green; dispatched faithfully, the same removal refunds the customer twice. A further
case redelivers while the run is still waiting on another call, which is the only scenario where
the execution-row guard stands alone rather than being covered by the terminal-run check beside it.ResumeApprovedRun refuses to
act on a terminal run, and nothing reached that check.WebhookReceiver and AutomationDispatcher catch a constraint violation as a normal outcome, and
DetectsUniqueViolations keeps that catch narrow. Widening either to every QueryException left
all 28 webhook and idempotency tests green — they all reach a healthy database — while in
production it would answer this webhook was already processed to a delivery that was in fact
dropped. The insert now fails for a reason that is not uniqueness, and the fault has to come back
out as a fault.Security/TenantScopeCoverageTest (T2) — the tenant scope's opt-in, which was a convention
rather than a control. Removing use BelongsToTenant; from each of the 26 models that carry it,
one at a time, found six that left the whole suite green: Approval, AuditLog, Observation,
WebhookDelivery, ChannelDelivery and ChannelLinkCode — the first two being the record of who
authorised a destructive tool call and the record that it happened. It hid because the write side
never depended on the trait (ApprovalManager::request() and AuditLogger::record() set
tenant_id explicitly) and because no test ever performed a cross-tenant read of those models —
a deleted scope is invisible to a same-tenant read. The new test asks the migrated schema which
tables carry a tenant_id column and requires the trait on every model mapping to one, so the
model this is really about — the one added next month — cannot omit it. ProviderCredential is the
single exemption, pinned by a test of its own.Security/QueuedJobTenancyTest (T2) — the tenant a queued job carries.
ResolvesPandoraContext re-enters it, with a docblock naming the stake: "Forgetting this is the
classic way a queued job silently reads across every tenant." Removing the re-entry left all 1,818
tests passing, twice — once by nulling the carried tenant, once by dropping the wrapper so the job
inherits the worker's context. Under sync the ambient tenant stands in for the carried one; and
losing a tenant does not make a read fail, it makes it wider, so the job finds its run and
succeeds and every "it worked" assertion passes. The new tests dispatch from outside any tenant,
the way a worker actually starts, and assert the leak: an audit row must carry the tenant the job
was handed, and a job given another tenant's run id must leave that run in queued.Approval and AuditLog in TenantIsolationTest (T2). The two objects with
real authority deserve an assertion a reader can find by name; the approval case asserts
unreachability by id through ApprovalManager::approve(), not merely absence from a list.Security/UntrustedContextTest, InjectionToDestructiveCallTest,
ApprovalArgumentFidelityTest, ApprovalFloorAgreementTest (T1). The end-to-end one is the
sentence the threat model actually makes and nothing had ever exercised: a model reads a poisoned
document and then demands a refund, in one run, through the real loop. The fake provider obeys the
injection completely, on purpose — the mitigation has to be the layers, not the model's judgement.Security/CredentialExtractionTest (T4) — the extraction clause, which had no test.BroadcastTest had one called
"versions and redacts every broadcast payload" that asserted nothing about redaction and passed
with the redactor deleted from the base class. Renamed to what it does, with the promised
assertion added beside it.Phase 9's threat audit begins. Four of the fifteen threats now have tests that were checked by removing the mitigation and watching them go red — and the first four found one live vulnerability, one criterion that described behaviour the package does not have, and one rule that had never been enforced anywhere.
The MCP HTTP client no longer follows redirects (SSRF). Guzzle follows them by default, so a
hostile or compromised MCP server could answer 302 Location: http://169.254.169.254/latest/meta-data/ and have Pandora re-send its POST to the cloud metadata
endpoint — across an HTTPS-to-HTTP downgrade, into the link-local range — then return the response
body to the model as tool output. An MCP endpoint is operator-configured precisely so that nothing
on the far side chooses where this application connects; a Location header was a way around
that. Redirects are now disabled at the client and a 3xx is refused with a new redirected reason
rather than reported as server_unavailable, which would have read to an operator as an outage.
No action needed on upgrade. A legitimate MCP server does not redirect its JSON-RPC endpoint;
if one in your deployment does, point endpoint at the final URL. The refusal names the location
it declined to follow.
Hidden until now because every MCP test binds FakeMcpServer in place of the HTTP transport — a
fake that never had a URL cannot lose one. Mcp/TransportUrlOriginTest drives the real transport
against Http::fake() and asserts the URL requested, not merely the answer received.
Architecture/NoOutboundHttpFromToolsTest (T6a). No core tool makes an outbound request, and
now nothing can add one quietly: the source, the stream wrappers and the constructor wiring of
every shipped tool are all checked. The threat model's SSRF allowlist is documented as a
specification for a tool that does not exist rather than a control that is running, and this test
is what goes red on the day it needs to be built.Skills/UntrustedSkillTest (T9). A hostile skill body — install instructions, a shell command,
an embedded tool call, a prompt-injection payload — is stored verbatim, lands disabled, grants
nothing by declaring required_tools, and executes nowhere.Architecture/ModuleBoundaryTest asserts T15 over every model. No $guarded = [], an explicit
$fillable on all twenty-nine, and no unserialize() anywhere in src/.src/ reads them: a skill can be imported, attached to an agent and shown
on its detail page, and its text reaches no prompt because nothing composes it into one. Skills are
inert, not merely unprivileged. The test asserts the current state, so wiring them into context
turns it red and the untrusted-content handling gets built at the same time rather than assumed.Installing v0.1.0 from Packagist into a fresh Laravel application — which nobody had done, because every test in the suite runs against the source tree through Testbench — found the package's most visual feature silently absent on a stock install.
pandora:status no longer calls the control center "enabled" when it cannot run.
pandora.ui.enabled says the control center is wanted; Livewire says it can exist. The service
provider returns early and registers no /pandora route at all when the class is missing, but the
status line read only the config flag. Following the documented path on a stock install therefore
produced a 404 on the thing the README leads with. Three states now — headless, unavailable,
enabled.pandora:install names Livewire as its own step, instead of closing with "Then open /pandora"
and never mentioning it.composer update three times. A single curl error 60 fetching one zipball turned
the whole matrix red with nothing wrong in the package. Cosmetic before; not any more, now that a
green matrix is a required check on a branch where merging publishes.tests complete. A branch ruleset names the checks it
requires and a matrix expands into one check per leg, so requiring them individually means editing
the ruleset every time a database is added — and a leg added but not required is a leg nobody has
to pass. It uses an explicit result check rather than bare needs:, because a skipped or cancelled
job does not fail its dependents.docs/development/fake-boundaries.md gains a sixth entry: the suite's own dependencies.
livewire/livewire is a dev dependency, so class_exists() is true for every test that will ever
run and the code handling its absence is unreachable by construction — untestable by layout rather
than untested by oversight. It is the same lesson as the two Phase 6 defects, in a place nobody had
thought to look, and it was only findable by installing the published package.The first published release. Nine phases of work: a durable agent kernel, tools with approvals, memory, automations, delegation, an MCP client and server, workspaces on object storage, channels with a Slack reference extension, and a Livewire control center — 1,756 tests, PHPStan level 8 with no baseline, Pint clean.
It is 0.x deliberately. The public API is usable and in use; it is not yet promised. Phase 9 —
which proves every T1–T15 threat by removing its mitigation and watching the test fail — stands at
3 of 34 criteria. docs/product/support-statement.md names what is supported, what is excluded by
design, and what ships known untested. Read it before depending on this in production.
Everything below this heading is the development history that produced it, kept by phase.
HashEmbeddingProvider is lexical, not
semantic; the vector path is real, the semantics are not. Configure a real provider for recall.See docs/development/fake-boundaries.md for the full inventory of what the test suite's fakes
structurally cannot prove.
An agent that remembers is an agent that will repeat something, so the security question changes from "may this actor do this?" to "whose was this, and who is standing here now?" — asked at retrieval, about a fact written by someone no longer in the room.
MemoryItem with six scopes (global, tenant, user, agent, conversation,
workspace), six types, provenance, confidence, sensitivity and expiry.recall tool has one parameter and it is the
search text.EmbeddingProvider and VectorStore contracts, a portable brute-force
store, and a pgvector adapter with its own mandatory CI leg. A store is an
accelerator, never an authority: everything it proposes is re-filtered by
scope before it is returned, and an unreachable store degrades to lexical and
records the degradation.remember and recall tools, and pandora:memory:sweep / :forget /
:export.AttributeAllowlist (no toArray() path to a prompt),
context files resolved against configured roots only, conversation
summarisation as a stored artefact, and redaction inside ContextBuilder.pandora.features.workspaces (off). The code and its tests stay in
the tree; what is withheld is the way in, from everybody, including an
operator holding every ability. Deferred because creating a workspace means
choosing a root, and a UI field that accepts a root path accepts /.BelongsToTenant stamps tenant_id on creating and the guard ran on
saving, one hook too early.LIKE is case-sensitive and the other three engines' are not.pandora:install runs the migrations when it is not interactive.
--no-interaction means "take the default answers", and the default answer is
yes; it used to print "not run (non-interactive)" and exit 0 with no schema,
leaving a scripted install no error to detect. --no-migrate is still the way
to opt out, and the command now verifies the schema exists afterwards rather
than reporting success on the strength of having called migrate.publishesMigrations(),
following the application's database.migrations.update_date_on_publish
setting. The packaged files are named 0001_01_01_* so they sort among
themselves; a host that took those names verbatim could not order its own
migrations relative to Pandora's.pandora:agent:list reports a run count per agent, so the Agents page can
be cross-checked against it.pandora.access — the ability an authenticated user holds by
default — and the listing is filtered by memory scope and status, never by
viewer. Reading now requires pandora.memory.manage, the same ability
approving and forgetting already required, and the sidebar entry is filtered on
it too. Found by the Phase 5 host walkthrough. Hosts that granted
pandora.access broadly and pandora.memory.manage narrowly were affected;
no configuration change is needed to take the fix.How can I help you explore Laravel packages today?