219 lines
37 KiB
Markdown
219 lines
37 KiB
Markdown
# Plugin Runtime — Internal Self-Audit Findings
|
|
|
|
**Phase:** 06
|
|
**Author:** Claude Opus 4.7 (1M context)
|
|
**Last revised:** 2026-05-19
|
|
**Status:** **Initial cut for B-006 audit-package.** Findings here are the gaps the agent identified during the threat-model + attack-surface authoring pass. They are NOT a substitute for the external pen-test; they are the input the auditor receives to know what we already know.
|
|
|
|
Each finding maps back to an asset in `docs/security/plugin-threat-model.md` (`A-NN`) and a folder-per-finding in `docs/improvement-plan/06-plugin-system/security-findings/F-NNN-…md` if/when it reaches Critical / High status.
|
|
|
|
Severity scale here: **Critical** = production-blocker, **High** = remediate before sign-off, **Medium** = remediate or document as accepted residual, **Low** = backlog.
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| ID | Severity | Asset | Status | One-line |
|
|
| ----- | ---------- | ---------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| F-001 | Medium | A-02 | **backend closed 2026-05-19; list-level UI closed 2026-05-26; nonrepudiation metadata closed 2026-05-28; guided dialog deferred** | `TenantPluginCapabilityGrant` Prisma model + migration `20260519010000_06_plugin_capability_grant`. `grantPluginCapabilities` service upserts one row per (tenant, plugin, capability); rejects capabilities not declared in the manifest. `enablePlugin` runs `assertConsentSatisfied` and refuses to flip Plugin.enabled=true until every declared capability has a grant (`capability_consent_missing` → HTTP 409). Disable always allowed (kill-switch parity). Route exposes `POST ?op=grant` with `{ pluginId, grants[] }`. Env opt-out `PLUGIN_REQUIRE_CAPABILITY_CONSENT=false` for back-compat dev/test. Tenant settings now requires a reviewed-capabilities checkbox and server-generated manifest digest before writing missing grants; stale manifests are rejected. Grant audit events include plugin version, review digest, approved capability names, approved scope JSON, and the matching manifest scope snapshot. A fuller guided tenant-admin consent dialog remains deferred. |
|
|
| F-002 | Medium | A-01, A-11 | **partially closed 2026-05-27; Wasmtime track remains external hardening** | Plugin invocation now runs in a killable worker-thread sandbox and enforces `PLUGIN_MAX_WALL_CLOCK_MS` (default 5000 ms) with `worker.terminate()`, so a pure-WASM loop with zero host calls no longer blocks the parent worker indefinitely. Remaining hardening: V8 WebAssembly still lacks per-instruction fuel/epoch interruption, so CPU accounting is wall-clock coarse and a future Wasmtime/Component-Model runtime remains the preferred security-audit upgrade path. Keep tight `PLUGIN_MAX_FUEL`, per-plugin worker concurrency, and BullMQ job timeouts in production. |
|
|
| F-003 | Low | A-05 | **closed 2026-05-19; operator verifier shipped 2026-05-29** | Per-tenant hash chain landed: `PluginCapabilityUse.prevHash` + `rowHash` columns (migration `20260519000000_06_plugin_capability_use_chain`), canonical-form spec in `packages/plugin-runtime/src/audit-chain.ts` (10-field deterministic encoding, SHA-256), worker handler stamps both columns per batch with optional `getChainHead` seam so chains span batches. `verifyPluginCapabilityUseChain` walks an ordered slice and reports first break with `prev_mismatch` / `row_hash_mismatch`. Chain-helper, handler, and CLI tests cover stamping, cross-batch seeding, soft-fail on `getChainHead` error, intra-batch threading, tamper/deletion detection, and operator verification through `toolctrl-cli plugins verify-audit-chain <tenantId>`. Pre-F-003 rows are skipped without flagging. A production web endpoint remains deferred; the operator CLI is the supported verification surface. |
|
|
| F-004 | (reserved) | — | — | placeholder for IDs that get split out from F-005. |
|
|
| F-005 | High | A-02, A-08 | **closed 2026-05-19 (Phase A + Phase B)** | Phase A (commit `5d9b97cd`): trust-store gate + signedBy/key match. Phase B (this commit): install path now reads the .wasm binary from `PLUGIN_BINARY_BASE_PATH/<id>/<version>/plugin.wasm` and runs `verifyPluginSignature` against the manifest. Rejects with `binary_not_found` (404), `binary_verification_failed` (403), or `signature_key_*` (403). Dev escape via `PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true` documented as non-production. 4 new tests with temp-dir fixture cover missing binary, hash mismatch, submitted-sha-vs-manifest-sha mismatch, and the escape hatch. |
|
|
| F-006 | Low | A-06 | **closed 2026-05-19** | Stage-2 token-aware scan landed in `migrations.ts`: `tokenizeSqlIdentifiers` walks the SQL respecting single-quoted strings, double-quoted identifiers, dollar-quoted strings, line comments, and block comments. `findForbiddenKeyword` scans the identifier stream for DROP DATABASE / GRANT / REVOKE / TRUNCATE / SET ROLE / PG\_\*. Replaces the previous substring-only check (which both false-positived on string-literal content AND was bypassable through obfuscation). 7 new tests cover string-literal isolation, dollar-quote isolation, comment isolation, keyword detection, and the previously-broken DEFAULT-with-grant false-positive case. Stage-1 substring scan retained as belt-and-braces; only fires when a forbidden token appears OUTSIDE every string literal. |
|
|
| F-007 | Low | A-06 | **closed 2026-05-19** | `applyPluginMigration` now emits a `plugin.migration.execute_statement` info line BEFORE each `executeRawUnsafe`. Includes `statementIndex`, `statementCount`, and a 200-char `statementPreview`. The matching `execute_failed` log also includes the statement index. 2 new tests. |
|
|
| F-008 | Medium | A-06 | **closed 2026-05-19** | `validatePluginMigration` now caps statement count at `DEFAULT_MAX_STATEMENTS_PER_MIGRATION` (32); per-call `maxStatements` override available. Code `too_many_statements` on `PluginMigrationError`. 3 new tests. |
|
|
| F-009 | Medium | A-07 | **closed 2026-05-19** | `installPlugin` now rejects when the tenant has reached `PLUGIN_MAX_INSTALLS_PER_TENANT` (default 50; set to 0 to disable). New code `tenant_install_limit_exceeded` → HTTP 429. 3 new tests. |
|
|
| F-010 | Low | A-08 | closed (test landed 2026-05-19, commit `bf639ac7`) | CSRF body-consumption regression — verified safe when `x-csrf-token` header is set; install route documents this contract. Negative-path test confirms a clean error (not the opaque undici `TypeError: unusable`) when callers omit the header. |
|
|
| F-011 | Medium | A-08 | **closed 2026-05-19** | Install route now runs a SharedStore sliding-window rate limiter (`createSlidingWindowRateLimiter`) keyed on `tenant:<id>`. Default `PLUGIN_INSTALL_RATE_LIMIT_PER_MIN=5`; over-limit returns 429 + `Retry-After: 60`. Production fails closed on infra errors unless `PLUGIN_RATE_LIMIT_FAIL_OPEN=true` is explicitly configured; non-production remains fail-open for operator ergonomics. 3 new tests cover allowed/denied/infra-error paths. |
|
|
| F-012 | Medium | A-09 | **closed 2026-05-26** | Plugin hook fanout is now wired: `plugin.hook.*` outbox events route to `outbox.plugin-hook`, the general worker's `plugin-hook-fanout` consumer scopes enabled-plugin lookup by `tenantId`, filters manifest-declared hooks, and enqueues one idempotent `toolctrl.plugin-invoke` job per subscriber. Sandbox execution remains isolated on `WORKER_QUEUE_GROUP=plugin`. The fanout path is tenant-scoped and does not cross tenant boundaries. |
|
|
| F-013 | Low | A-10 | **accepted-residual 2026-05-19** | A tenant user with control over plugin input can drive their own plugin to auto-disable. Confirmed not a privilege escalation: the threat-model invariant is that disabling a plugin for one's own tenant is not a security boundary. Documented in plugin-threat-model.md A-10 "Elevation" row. |
|
|
| F-014 | Medium | A-12 | **closed 2026-05-19** | `sanitizePluginOutput` now carries a total-bytes budget across the walk (default 1 MiB, env override `PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES`). Once exhausted further string leaves become `[SIZE_LIMIT]` markers. The output-size budget is closed; the remaining caveat is regex-based sanitization for encoded secrets. 4 new tests cover budget exhaustion in objects + arrays, default-fits cases, and invalid-env fallback. |
|
|
| F-015 | Critical | A-13 | **bootstrap wiring closed 2026-05-26; production runtime gate remains B-006** | Handler now dispatches to `handlePluginInvokeJob` when `pluginInvokeDeps` is injected; throws (fail-loud, BullMQ dead-letters) when not. `apps/worker/src/index.ts` composes production deps when `WORKER_QUEUE_GROUP=plugin`. Bootstrap module `apps/worker/src/jobs/plugin-invoke-bootstrap.ts` wires Prisma `loadPlugin`, filesystem `loadBinary` (from `PLUGIN_BINARY_BASE_PATH/<id>/<version>/plugin.wasm`), `verifyPluginSignature`, SharedStore sliding-window rate-limit, sandbox+gateway `invokeInSandbox`, log-based `recordAudit`, and SharedStore crash-tracker. Remaining signoff is the broader production WASM runtime review, not the SharedStore limiter wiring. Host-fns for the 9 declared capabilities remain empty — any capability call denies via `no_host_implementation` through the gateway (intentional foundation scope). |
|
|
| F-016 | Low | A-14 | **documented 2026-05-19** | The stale runtime-enable toggle has been replaced by the current production gate: `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` is required before tenant plugin runtime operations are allowed in production. Emergency stop remains operational: stop/scale `worker-plugin`, pause/drain `toolctrl.plugin-invoke`, and disable affected plugins through lifecycle controls. |
|
|
| F-017 | High | A-02 | closed (commit `0b2995ad`, 2026-05-19) | Schema widened to declare optional `minHostVersion` / `maxHostVersion`. `PluginLifecycleService.extractVersionConstraints` is now redundant (the parsed manifest already carries the fields) but remains harmless; consider collapsing in a follow-up. |
|
|
| F-021 | Medium | A-06 | **closed 2026-05-19** | `validateCreateTable` now requires `tenantId` to appear as a column-definition header (preceded by `(` or `,`, optionally double-quoted, followed by whitespace before the type). Column comments / DEFAULT literals containing the word `tenantId` no longer satisfy the check. 5 new tests cover the positive + negative paths. |
|
|
| F-022 | Medium | A-08 | **closed 2026-05-26** | Plugin lifecycle routes now split the broad account-settings permission into `app.plugins.use` for read/list/audit surfaces and `app.plugins.manage` for install, capability grant, enable, and disable. Default seeds assign manage to SuperUser/Admin and use to all default app roles. |
|
|
| F-023 | Medium | A-12 | **closed 2026-05-19** | `sanitizePluginOutput.walk()` now carries a `WeakSet` seen-tracker; circular refs are replaced with `[CIRCULAR]` instead of overflowing the stack. 3 new tests cover self-reference, two-step cycle, and circular array. |
|
|
| F-018 | Medium | A-12 | **documented 2026-05-19** | Sanitization is best-effort regex defence-in-depth against PANs / JWTs / IBANs / API-keys; a plugin that custom-encodes data evades it. Confirmed: the primary trust boundary remains `db.read` capability denial — a plugin that cannot read tenant data cannot exfiltrate it. Documented in plugin-threat-model.md A-12 "Info disclosure" row and the cross-cutting invariant §3.4. |
|
|
| F-019 | Medium | A-04 | **documented 2026-05-19** | Capability gateway `onCall` / `onDenied` callbacks fire after the host-fn returns or throws. Verified by reading capability-gateway.ts:121-137: success outcome before return, error outcome before re-throw. The recorder always sees an outcome — there is no "lost" call. Documented in plugin-threat-model.md A-04. When real host-fn implementations land each will need to keep this invariant; tracked as a per-PR check item. |
|
|
| F-020 | Low | A-05 | **closed 2026-05-19** | Retention job now logs surviving row count after each sweep. When count exceeds `warnRowCountThreshold` (default 10M) the job emits a `plugin-audit-retention.table-size-warning` warn line. count() soft-fails so a flaky DB doesn't poison the retention result. Worker bootstrap wires `prisma.pluginCapabilityUse.count()`. 4 new tests. |
|
|
|
|
---
|
|
|
|
## F-005 (High, closed) — Install path re-verifies binary signature
|
|
|
|
**Severity:** High (closed 2026-05-19, Phase A + Phase B)
|
|
**Asset:** [A-02 Plugin manifest](plugin-threat-model.md#a-02-plugin-manifest), [A-08 Tenant install API](plugin-threat-model.md#a-08-tenant-install-api)
|
|
|
|
### Description
|
|
|
|
`apps/web/app/routes/api.tenants.$tenant.plugins.tsx` still delegates install requests to `installPlugin({ tenantId, manifestJson, wasmSha256, signaturePublicKey, actor })`, but the lifecycle service no longer stores caller-supplied binary metadata without verification.
|
|
|
|
Installs are gated, not accepted merely because a manifest is valid. The lifecycle service requires `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` in production, parses the manifest, enforces inclusive `minHostVersion` / `maxHostVersion` when present, checks the trusted publisher key list, requires `signaturePublicKey` to match `manifest.signedBy`, reads the pre-positioned binary from `PLUGIN_BINARY_BASE_PATH/<pluginId>/<version>/plugin.wasm`, runs `verifyPluginSignature`, cross-checks submitted SHA vs `manifest.wasmSha256`, rejects duplicate tenant/plugin installs, and enforces the tenant install cap. Production must not set `PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true`.
|
|
|
|
### Reproduction
|
|
|
|
```bash
|
|
curl -X POST https://staging/api/tenants/<tenant>/plugins \
|
|
-H "x-csrf-token: <token>" \
|
|
-H "content-type: application/json" \
|
|
-d '{
|
|
"manifestJson": <manifest object pointing at a published binary>,
|
|
"wasmSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
"signaturePublicKey": "<some-published-public-key>"
|
|
}'
|
|
# Current result: 403 with "binary_verification_failed"; non-production
|
|
# environments may opt into PLUGIN_INSTALL_SKIP_BINARY_VERIFY for local tests.
|
|
```
|
|
|
|
### Closure evidence
|
|
|
|
- `PluginLifecycleService.installPlugin` runs the production gate, parses trusted keys via `parseTrustedPublicKeys`, checks `manifest.signedBy === signaturePublicKey`, enforces the host-version range, loads the `.wasm` from disk, calls `verifyPluginSignature` before create, rejects duplicate `(tenantId, pluginId)` rows, and enforces `PLUGIN_MAX_INSTALLS_PER_TENANT`.
|
|
- Missing binaries fail with `binary_not_found`; invalid hashes/signatures fail with `binary_verification_failed`; untrusted or mismatched publisher keys fail with `signature_key_*`.
|
|
- Invocation-time verification in `apps/worker/src/jobs/plugin-invoke.ts` remains as a second line of defence.
|
|
|
|
---
|
|
|
|
## F-010 (closed) — CSRF body-consumption bug from BLOCKERS B-009
|
|
|
|
**Severity:** Low (closed)
|
|
**Asset:** [A-08 Tenant install API](plugin-threat-model.md#a-08-tenant-install-api)
|
|
|
|
### Description
|
|
|
|
BLOCKERS.md B-009 originally noted a follow-up bug: older `validateCSRFToken` behavior could surface `TypeError: unusable` whenever a route action had already read the body before validation.
|
|
|
|
The plugin install action (`api.tenants.$tenant.plugins.tsx:43`) does:
|
|
|
|
```ts
|
|
await validateCSRFToken(request); // line 46
|
|
const body = (await request.json()) as { ... }; // line 63
|
|
```
|
|
|
|
CSRF validation happens **before** the JSON body is read. The current validator also keeps the JSON body safe by checking `x-csrf-token` before any body fallback, and by exposing `{ formData }` for single-read form actions.
|
|
|
|
### Reproduction
|
|
|
|
```bash
|
|
# Form-data POST attempting CSRF + JSON body
|
|
curl -X POST https://staging/api/tenants/<tenant>/plugins \
|
|
-H "x-csrf-token: <token>" \
|
|
-d '{"manifestJson":...}' \
|
|
-H "content-type: application/json"
|
|
# Expected: 201 Created (if both CSRF + JSON body work)
|
|
# Actual: verified 201 when the header token is present; clean CSRF error when absent
|
|
```
|
|
|
|
### Remediation plan
|
|
|
|
1. Add an integration test `apps/web/app/routes/api.tenants.$tenant.plugins.test.ts` that hits the install path with `content-type: application/json` and an `x-csrf-token` header to verify the happy path.
|
|
2. Keep JSON callers on the header-token contract.
|
|
3. Use `validateCSRFToken(request, { formData })` for form actions that intentionally parse the body before validation.
|
|
|
|
### Remediation commits
|
|
|
|
| SHA | Summary |
|
|
| ---------- | ---------------------------------------------------------------------------------------- |
|
|
| `bf639ac7` | test(plugin-routes): verify install route immunity to CSRF body-already-consumed (F-010) |
|
|
|
|
### Verification outcome (2026-05-19)
|
|
|
|
- The real `validateCSRFToken` short-circuits at `session.server.ts:250` whenever the `x-csrf-token` header is present, **before** any `request.clone().text()` call. The install route therefore never triggers the body-clone path when callers honour the header contract.
|
|
- The new test pair in `apps/web/app/routes/api.tenants.$tenant.plugins.test.ts` replaces the csrf mock with a partial real implementation that mirrors header-first / body-fallback. With the header set, install returns 201 and `request.json()` succeeds afterwards. Without the header, the test surfaces a clean `CSRF Request Token not included.` error (not the opaque undici `TypeError: unusable`) — proving the route fails cleanly even in the worst case.
|
|
- Downgraded from High → **Low** and closed.
|
|
- 2026-05-25 follow-up: `validateCSRFToken(request, { formData })` is now covered in `apps/web/app/utils/session.server.test.ts`, closing the broader UI form-body ordering bug that originally prompted the B-009 note.
|
|
|
|
---
|
|
|
|
## F-015 (Critical, bootstrap wiring closed) — Verify production rate-limit wiring
|
|
|
|
**Severity:** Critical for production signoff; internal bootstrap wiring closed
|
|
**Asset:** [A-13 Rate-limit counters](plugin-threat-model.md#a-13-rate-limit-counters)
|
|
|
|
### Description
|
|
|
|
`apps/worker/src/jobs/plugin-invoke.ts:91` declares `consumeRateLimit` as a dependency seam. Tests inject a fake. The handler returns `rate_limit_exceeded` when `allowed: false`.
|
|
|
|
The production worker bootstrap now wires a real SharedStore-backed limiter. A no-op would allow a single plugin to saturate the worker pool, so this remains an explicit audit checkpoint whenever production bootstrap code changes.
|
|
|
|
### Reproduction
|
|
|
|
Read `apps/worker/src/jobs/plugin-invoke-bootstrap.ts` wherever `handlePluginInvokeJob` is wired in production. Confirm `consumeRateLimit` calls a SharedStore-backed sliding-window or token-bucket. If a future refactor returns `{ allowed: true, remaining: Infinity }`, the rate limit has regressed to a no-op.
|
|
|
|
### Remediation plan
|
|
|
|
1. Grep for `consumeRateLimit` usage in `apps/worker/src/`.
|
|
2. Verify the wired implementation; if no-op, replace with a real SharedStore-backed limiter.
|
|
3. Add a test that asserts the production wiring rate-limits at the configured window.
|
|
|
|
### Remediation commits
|
|
|
|
| SHA | Summary |
|
|
| ---------- | ------------------------------------------------------------------------ |
|
|
| `cbac8d3d` | Wire production plugin invoke bootstrap with SharedStore rate limiting. |
|
|
|
|
### Verification outcome (2026-05-26) — bootstrap wiring closed
|
|
|
|
F-015's internal bootstrap gap is closed in code. `apps/worker/src/jobs/plugin-invoke-bootstrap.ts` wires `handlePluginInvokeJob` into the dedicated `WORKER_QUEUE_GROUP=plugin` runtime with Prisma plugin lookup, binary loading, signature verification, SharedStore-backed rate limiting, sandbox invocation, audit logging, and crash tracking.
|
|
|
|
The generic handler path in `apps/worker/src/handlers.ts` now fails loudly if `pluginInvokeDeps` is missing, so a misconfigured worker dead-letters the job instead of silently acknowledging it as skipped. Current coverage includes the plugin invoke handler/bootstrap tests and deploy topology verification that checks `worker-plugin` as a separate Coolify resource.
|
|
|
|
Remaining external signoff is the production WASM security review tracked by B-006 and `docs/security/plugin-runtime-signoff.md`; it is not a blocker for the F-015 bootstrap wiring itself.
|
|
|
|
---
|
|
|
|
## F-017 (closed) — Manifest schema declares host-version constraint fields
|
|
|
|
**Severity:** High (closed 2026-05-19, commit `0b2995ad`)
|
|
**Asset:** [A-02 Plugin manifest](plugin-threat-model.md#a-02-plugin-manifest)
|
|
|
|
### Description
|
|
|
|
Historically, `packages/plugin-runtime/src/version-check.ts` checked `manifest.minHostVersion` and `manifest.maxHostVersion`, but the strict manifest schema did not declare those fields. That made host-version constraints unusable because manifests carrying them failed schema parsing before the compatibility check ran.
|
|
|
|
Current status: the schema now declares optional `minHostVersion` / `maxHostVersion` fields, and install-time lifecycle validation enforces the inclusive host-version range before a plugin row is created.
|
|
|
|
### Reproduction
|
|
|
|
Historical reproduction only:
|
|
|
|
1. Submit a manifest with `minHostVersion: "1.0.0"` before commit `0b2995ad`.
|
|
2. Expected: install succeeds or fails with a host-version compatibility error.
|
|
3. Historical result: install failed with `manifest_invalid` because Zod's `.strict()` rejected the unknown field.
|
|
|
|
### Closure evidence
|
|
|
|
- `packages/plugin-runtime/src/manifest.ts` accepts optional `minHostVersion` and `maxHostVersion`.
|
|
- `packages/plugin-runtime/src/version-check.ts` validates the parsed manifest bounds against the running host version.
|
|
- `PluginLifecycleService.installPlugin` rejects incompatible plugins before create.
|
|
|
|
### Remediation commits
|
|
|
|
| SHA | Summary |
|
|
| ---------- | -------------------------------------------------------------------------------------------- |
|
|
| `0b2995ad` | fix(plugin-runtime): widen manifest schema to declare host-version constraint fields (F-017) |
|
|
|
|
### Verification outcome (2026-05-19)
|
|
|
|
- Both fields added to `PluginManifestSchema` as optional `z.string().regex(SEMVER_PATTERN)`. Schema remains `.strict()`.
|
|
- Four new tests in `manifest.test.ts`: accepts omitted, accepts well-formed pair, rejects non-SemVer `min`, rejects non-SemVer `max`. Existing 13 tests + 11 version-check tests still pass.
|
|
- `PluginLifecycleService.extractVersionConstraints` is now redundant — the parsed manifest already carries the fields. Left in place because removing it requires touching out-of-scope `PluginLifecycleService.ts` per the session policy. Worth collapsing in a follow-up PR (no behaviour change expected — both paths land on the same struct via `{ ...manifest, ...rawConstraints }`).
|
|
- Closed.
|
|
|
|
---
|
|
|
|
## Other findings (Medium / Low)
|
|
|
|
The remaining findings F-001, F-002, F-003, F-006, F-007, F-008, F-009, F-011, F-013, F-014, F-016, F-018, F-019, F-020 are tracked in the summary table above. Each becomes a stub file under `docs/improvement-plan/06-plugin-system/security-findings/F-NNN-….md` if and when it is scheduled for remediation.
|
|
|
|
---
|
|
|
|
## Change log
|
|
|
|
| Date | Change | Author |
|
|
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
|
| 2026-05-19 | Initial 20-finding cut produced during threat-model pass. F-005/F-010/F-015/F-017 flagged as High for sign-off remediation. | Claude Opus 4.7 (1M context) |
|
|
| 2026-05-19 | Self-audit pass. F-017 closed via commit `0b2995ad` (schema widened). F-010 closed via commit `bf639ac7` (regression test landed; route confirmed safe under `x-csrf-token` header contract). F-005 confirmed open + concrete remediation plan added (trust-store gate as Phase A, in-PR scope; binary-positioning gate as Phase B, out of scope). F-015 **escalated High → Critical** during this pass because production plugin invoke wiring and rate limiting still needed proof at that point. Three new findings added: F-021 (migration tenantId-column regex bypass, Medium), F-022 (permission conflation across all plugin lifecycle ops, Medium), F-023 (sanitize missing circular-reference guard, Medium). | Claude Opus 4.7 (1M context) |
|
|
| 2026-05-26 | Docs sync: F-012 updated to closed after `plugin.hook.*` events gained the `outbox.plugin-hook` route and the general-worker `plugin-hook-fanout` consumer that enqueues tenant-scoped `toolctrl.plugin-invoke` jobs for manifest-declared subscribers. | Codex |
|