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.
Phase 9's threat audit continues, and completes. T3 first: session isolation is enforced by a hash — seven components folded into one key — which makes it unusually auditable, because every component is independently removable and removing one is invisible unless a test varies exactly that component. Seven ablations, four load-bearing, two findings.
Then T5, whose criterion is the only one in the phase that names its own ablation. Ten ablations, nine load-bearing, one finding: the control that protects a write from an escaping symlink was unasserted, with a quota lookup refusing the escape first by accident.
Then the suite learned to run more than one process at a time, and that carries a fix. Four
findings so far shared one cause — a control that only exists between workers, tested by a suite that
has one — so criterion 27 was taken early. It found RunLock's database lease untested and a live
defect that could fail a run.
Then T7 (nine ablations, the wall-clock limit found to have no test at all), T8 (eight ablations, the deny half of layer 2 never exercised through delegation) and T13 (twenty-five ablations, five pages that could lose their gate with the suite green).
With T13 the removal audit is complete. Every T1–T15 mitigation has now been removed, one at a time, and the failure recorded — ten sessions of it. It produced two live security fixes shipped in v0.1.3 and one concurrency fix here, and closed fourteen coverage gaps across ten threats. The recurring finding never changed shape: a docblock stating a guarantee precisely, and nothing but an accident asserting it.
Nine control-center gating tests (T13), one of them architectural. routes/web.php requires
authorization inside each component rather than relying on route middleware, and all eighteen
components complied — but nothing made them, and five could lose their gate with the whole suite
green: the agent, automation and run detail pages, the runs index, and the channel-link page.
Every index but one had a denial test; the detail pages had none at all, which is where an index's
one line becomes a prompt, a webhook secret or a full execution trace. The architectural test now
asserts that every class in src/UI/Livewire/ authorizes somewhere, so a nineteenth page cannot be
added ungated. ToolsIndex also read tools.io.view twice with only the template flag asserted,
not the check deciding whether schemas are assembled at all.
Five delegation deny-path tests (T8). AbilityIntersection::abilitiesOfAgent() resolves an
agent's tools as granted, minus denied, and removing the denied half left all 70 delegation tests
green. Every existing T8 test gives the parent an ability it simply lacks; none gives it one
explicitly denied — the carve-out idiom Agent::deniedTools() exists for. The escalation runs
opposite to the one the other tests guard: ignore the deny list and the parent is credited with a
tool an operator took away by name, the child intersects against that inflated set and receives it,
and at call time the gatekeeper checks the child's policy and the frozen list, neither of which
mentions the parent's deny list. Under the ablation the child's tool execution reads succeeded
where it should read denied — it runs. All five new tests fail under it; all 70 existing ones pass.
Seven limit-attribution tests (T7) and a SlowTool fixture. T7 asks for each limit to be
proved by removing the others, which is the only useful question about limits that overlap:
assertWithinBudget() checks four in a fixed order and the first to trip throws, so a test
asserting only "it stopped with a BudgetExceeded" passes with the limit it names deleted.
The wall-clock limit turned out to have no test at all — Run::hasExceededDeadline() had one
call site in src/ and none in tests/, and deleting the check left all 1,828 tests green. The
test that reads like its coverage is about the agent-scope token budget; RunState::TimedOut is
where every budget breach lands, so its name means "stopped by a limit" rather than "ran out of
time". Reaching the limit needed a tool that moves the clock from inside handle(), the only place
a test can act between two iterations of a run executing inline. Every new test sets every other
limit generously and asserts the message of the limit under test.
A concurrency harness — tests/Support/RunsConcurrently.php. Starts N real OS processes
against one database and releases them from a barrier, so a test can assert what happens when
workers genuinely collide. The barrier is the load-bearing part: application boot dwarfs the
contended section, so without a rendezvous the processes run one after another and a concurrency
test passes with the control deleted. Queue/ConcurrentHarnessTest asserts the harness's own
overlap before anything relies on it. Deliberately not pest --parallel, which would collide with
the shared-schema optimisation in TestCase.
Performance/ConcurrentRunsTest (criterion 27) — twenty concurrent runs against one agent
complete without starvation, lost steps or duplicated execution. It immediately established that
RunLock's database lease, which the class documents as "the authority", can be deleted with all
22 serial lock tests green — including one named "grants ownership to one worker and refuses a
second". Five simultaneous processes all acquire the same run.
Six workspace containment-layer tests (T5). LocalStorage::locate(mustExist: false)
re-resolves a write target that already exists, because a contained parent says nothing about what
the leaf is a link to — and that re-check could be deleted with all 1,828 tests green. Two tests
are named for exactly the case it protects. Both passed without it, because
WorkspaceFiles::write() calls storage->size() for quota accounting first, and size() resolves
with mustExist: true, so an escaping symlink is refused by a byte-count lookup several lines
before the containment check written for it is reached. The new tests drive LocalStorage
directly, which is the only way to reach the second layer with the first out of the way; three of
them fail when the re-check is removed. A fourth pins the quota ordering in place deliberately, so
that changing it is loud. The ablation also separated two controls the original tests read as one:
a symlinked directory is caught by the parent check, a symlinked leaf only by the re-check.
Two session-isolation tests (T3). actor_type could be dropped from Session::isolationKeyFor()
with all 1,820 tests still green, while dropping actor_id beside it failed two — the existing
"derives a different isolation key for every differing component" varies six of the seven components
and never the actor's type. The collision is reachable: ActorContext::system() takes an arbitrary
label as its id, so an automation labelled with a user's primary key shares that user's id and
differs only in type, and without the type in the key they resolve to one session. Separately,
SessionResolver folds the conversation into the origin so two conversations with the same agent
and actor do not share a boundary — a comment that was the only thing asserting it, since the unit
test passes origin ready-made and never exercises the resolver's composition. Both are now
asserted, and each ablation fails exactly the test that names it.
ProviderHealthMonitor::rowFor() used
firstOrNew() followed by save(), and provider_key is uniquely indexed. Two workers recording
the first outcome for the same provider at the same instant both read nothing, both build a row,
and the loser's INSERT was refused — with the exception coming out of the health write, out of the
provider call, and failing the run. A provider that answered perfectly well, a run destroyed by
its own bookkeeping. The window is narrow, since only the first write for a provider races and
every later one is an UPDATE, but it is exactly the window a fresh deployment starts in: workers
warm, health table empty. Now tolerates the duplicate and takes the row the other worker inserted.
Hosts running a single worker were never affected.T3's Claimed by column was incomplete. Summariser scopes its read by session_id, and
removing that filter leaves all four files T3 claimed green — it is Context/SummarisationTest
that catches it. The control was real and tested; the plan did not know where.
Two configured abilities gate nothing. pandora.audit.view is declared and read nowhere,
because the audit page it was written for does not exist; pandora.tools.manage likewise, because
the Tools page is read-only. Neither exposes anything — no audit record reaches any view and no tool
can be altered from the UI — but an operator granting or withholding either sees no difference and
cannot tell that from outside. Both are now named explicitly in a test rather than removed from the
config, since deleting a published key would break a host that references it, and both belong in the
v1.0 support statement.
DelegationDecision documented $withheldTools backwards. It read "abilities the parent held
and did not pass on", while AbilityIntersection::withheld() computes the other direction on
purpose — what the child agent was configured for and was refused — and says so emphatically. The
code was right; the parameter comment is now corrected.
T7's Claimed by column was wrong about the iteration limit. Removing it leaves all five of
the files T7 claimed green; what fails is Feature/AgentRunTest. The control was real and tested —
the plan did not know where.
Recorded, not fixed: the agent token budget is enforced twice. Deleting
assertWithinBudget()'s token comparison leaves the suite green, because
BudgetGuard::limitsFor(Run) reads the same token_budget column by another route. Defence in
depth rather than a hole, and not equivalent for a delegated run, where budgetOwner() charges the
agent at the root of the tree. Both layers are now asserted separately.
Recorded, not fixed: the provider health counters lose updates under concurrency.
recordSuccess() reads consecutive_successes, adds one and writes it back with no lock, so two
concurrent successes both read N and both write N+1. It cannot fail a run and it feeds hysteresis —
deciding whether a provider is degraded — rather than any security control. Making it exact means
holding a row lock across a read-modify-write on the hot path of every provider call, which is a
performance decision rather than a defect to quietly change inside an audit.
Recorded, not fixed: Session::belongsToActor() has no production call sites. It is called only
from a test, and its actorless guard is unreachable besides — ActorContext cannot produce a null
type, so the comparison below the guard would already fail. Its docblock states a rule about
system sessions that a reader would reasonably take for an enforced one, and it is not enforced
anywhere.
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.
How can I help you explore Laravel packages today?