Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Pandora Laravel Package

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.

View on GitHub
Deep Wiki
Context7
v0.1.4

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.

Added

  • 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.

Fixed

  • A provider health write could fail a run. 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.

Documentation

  • 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.

v0.1.3

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.

Security

  • 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.

Fixed

  • 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.

Changed

  • 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.

Added

  • 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.
  • Two idempotency tests that now dispatch what a queue would dispatch (T14). The retry cases sent 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.
  • A test for the stale Approve button on a cancelled run (T14). ResumeApprovedRun refuses to act on a terminal run, and nothing reached that check.
  • Two tests for what counts as a replay (T12). Replay protection is a unique INSERT, so both 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.
  • Two tests naming 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.
  • A broadcast test that checks what its name claims (T11). 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.
v0.1.2

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.

Security

  • 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.

Added

  • 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/.

Fixed

  • ADR-0008's last consequence was wrong, and is amended. It said skill instructions still reach the prompt. Nothing in 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.
v0.1.1

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.

Fixed

  • 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.
  • The README says plainly that the bare install is headless, and what to add for the UI.

Changed

  • CI retries 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.
  • The matrix reports through one check, 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.

Documentation

  • 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.
v0.1.0

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.

Known untested at 0.1.0

  • Two channel identities interleaving on one account in real time (Phase 8 §5). The sequential case is driven against a real Slack workspace and holds; the concurrent one needed a second Slack account that did not exist. A defect here would be a race in session resolution, not a wrong isolation key.
  • T1–T15 are not yet proved by mitigation-removal. Each threat names passing tests, and those tests have not all been audited against the bar Phase 9 sets for them.
  • Retrieval quality with the default embedding provider. 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin