From 980607c6020f1c10ff813a7832c3839eff362559 Mon Sep 17 00:00:00 2001 From: ajmallesh Date: Wed, 26 Aug 2026 19:26:36 -0700 Subject: [PATCH 01/11] feat(worker): add agentic static analysis Add the ten-stage Agentic SAST pipeline, confined repository tools, model runtime, prompt templates, and SARIF export. Make retries, repair sessions, reduced coverage, usage accounting, and model-output drift durable across Temporal replay and resume. Keep retry diagnostics in their actionable closed vocabulary. Package the Mantis-derived license material with the prompts that require it. --- .dockerignore | 2 + Dockerfile | 4 + LICENSES/Apache-2.0.txt | 201 +++ THIRD_PARTY_NOTICES.md | 36 + apps/cli/package.json | 2 +- apps/worker/package.json | 9 +- .../partials/capella-calibration-rules.hbs | 222 ++++ .../partials/capella-operating-principles.hbs | 68 ++ .../worker/prompts/partials/capella-tools.hbs | 14 + .../sast/capella/architecture.prompt.hbs | 85 ++ .../sast/capella/architecture.test.hbs | 4 + .../prompts/sast/capella/calibrate.prompt.hbs | 358 ++++++ .../prompts/sast/capella/calibrate.test.hbs | 4 + .../prompts/sast/capella/confirm.prompt.hbs | 63 + .../prompts/sast/capella/confirm.test.hbs | 4 + .../prompts/sast/capella/critic.prompt.hbs | 106 ++ .../prompts/sast/capella/critic.test.hbs | 4 + .../prompts/sast/capella/dedupe.prompt.hbs | 49 + .../prompts/sast/capella/dedupe.test.hbs | 4 + .../prompts/sast/capella/plan.prompt.hbs | 94 ++ .../worker/prompts/sast/capella/plan.test.hbs | 4 + .../prompts/sast/capella/research.prompt.hbs | 75 ++ .../prompts/sast/capella/research.test.hbs | 4 + .../prompts/sast/capella/review.prompt.hbs | 191 +++ .../prompts/sast/capella/review.test.hbs | 4 + .../sast/capella/threat_model.prompt.hbs | 100 ++ .../sast/capella/threat_model.test.hbs | 4 + .../prompts/sast/capella/triage.prompt.hbs | 27 + .../prompts/sast/capella/triage.test.hbs | 4 + apps/worker/src/ai/model-host.ts | 54 + apps/worker/src/ai/models.ts | 11 +- .../src/ai/pi/capella-agent-executor.ts | 572 +++++++++ apps/worker/src/ai/pi/capella-agent-types.ts | 38 + apps/worker/src/ai/pi/retry-settings.ts | 8 +- .../worker/src/ai/pi/structured-generation.ts | 118 ++ apps/worker/src/ai/pi/turn-error.ts | 43 +- apps/worker/src/ai/sast/capella/artifacts.ts | 568 +++++++++ apps/worker/src/ai/sast/capella/collectors.ts | 1084 +++++++++++++++++ .../src/ai/sast/capella/error-contract.ts | 45 + apps/worker/src/ai/sast/capella/errors.ts | 69 ++ .../src/ai/sast/capella/finding-types.ts | 227 ++++ apps/worker/src/ai/sast/capella/paths.ts | 63 + .../src/ai/sast/capella/prompt-context.ts | 171 +++ .../src/ai/sast/capella/prompt-loader.ts | 113 ++ apps/worker/src/ai/sast/capella/report.ts | 94 ++ .../src/ai/sast/capella/safe-failures.ts | 59 + .../src/ai/sast/capella/sarif-exporter.ts | 298 +++++ apps/worker/src/ai/sast/capella/schemas.ts | 159 +++ .../ai/sast/capella/stages/architecture.ts | 380 ++++++ .../src/ai/sast/capella/stages/export.ts | 161 +++ .../src/ai/sast/capella/stages/research.ts | 538 ++++++++ .../src/ai/sast/capella/stages/shared.ts | 295 +++++ .../src/ai/sast/capella/stages/verdicts.ts | 608 +++++++++ .../ai/sast/capella/temporal/activities.ts | 808 ++++++++++++ .../sast/capella/temporal/activity-types.ts | 294 +++++ .../src/ai/sast/capella/temporal/registry.ts | 67 + .../src/ai/sast/capella/temporal/workflow.ts | 481 ++++++++ .../src/ai/sast/capella/tools/confinement.ts | 511 ++++++++ .../worker/src/ai/sast/capella/tools/index.ts | 21 + .../ai/sast/capella/tools/repository-tools.ts | 377 ++++++ apps/worker/src/ai/sast/capella/types.ts | 283 +++++ apps/worker/src/ai/sast/capella/validation.ts | 607 +++++++++ apps/worker/src/ai/sast/sarif-profile.ts | 253 ++++ apps/worker/src/ai/sast/types.ts | 183 +++ apps/worker/src/ai/structured-generation.ts | 35 + apps/worker/src/services/error-handling.ts | 275 ++++- apps/worker/src/types/errors.ts | 25 + pnpm-lock.yaml | 45 +- 68 files changed, 11722 insertions(+), 65 deletions(-) create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 apps/worker/prompts/partials/capella-calibration-rules.hbs create mode 100644 apps/worker/prompts/partials/capella-operating-principles.hbs create mode 100644 apps/worker/prompts/partials/capella-tools.hbs create mode 100644 apps/worker/prompts/sast/capella/architecture.prompt.hbs create mode 100644 apps/worker/prompts/sast/capella/architecture.test.hbs create mode 100644 apps/worker/prompts/sast/capella/calibrate.prompt.hbs create mode 100644 apps/worker/prompts/sast/capella/calibrate.test.hbs create mode 100644 apps/worker/prompts/sast/capella/confirm.prompt.hbs create mode 100644 apps/worker/prompts/sast/capella/confirm.test.hbs create mode 100644 apps/worker/prompts/sast/capella/critic.prompt.hbs create mode 100644 apps/worker/prompts/sast/capella/critic.test.hbs create mode 100644 apps/worker/prompts/sast/capella/dedupe.prompt.hbs create mode 100644 apps/worker/prompts/sast/capella/dedupe.test.hbs create mode 100644 apps/worker/prompts/sast/capella/plan.prompt.hbs create mode 100644 apps/worker/prompts/sast/capella/plan.test.hbs create mode 100644 apps/worker/prompts/sast/capella/research.prompt.hbs create mode 100644 apps/worker/prompts/sast/capella/research.test.hbs create mode 100644 apps/worker/prompts/sast/capella/review.prompt.hbs create mode 100644 apps/worker/prompts/sast/capella/review.test.hbs create mode 100644 apps/worker/prompts/sast/capella/threat_model.prompt.hbs create mode 100644 apps/worker/prompts/sast/capella/threat_model.test.hbs create mode 100644 apps/worker/prompts/sast/capella/triage.prompt.hbs create mode 100644 apps/worker/prompts/sast/capella/triage.test.hbs create mode 100644 apps/worker/src/ai/model-host.ts create mode 100644 apps/worker/src/ai/pi/capella-agent-executor.ts create mode 100644 apps/worker/src/ai/pi/capella-agent-types.ts create mode 100644 apps/worker/src/ai/pi/structured-generation.ts create mode 100644 apps/worker/src/ai/sast/capella/artifacts.ts create mode 100644 apps/worker/src/ai/sast/capella/collectors.ts create mode 100644 apps/worker/src/ai/sast/capella/error-contract.ts create mode 100644 apps/worker/src/ai/sast/capella/errors.ts create mode 100644 apps/worker/src/ai/sast/capella/finding-types.ts create mode 100644 apps/worker/src/ai/sast/capella/paths.ts create mode 100644 apps/worker/src/ai/sast/capella/prompt-context.ts create mode 100644 apps/worker/src/ai/sast/capella/prompt-loader.ts create mode 100644 apps/worker/src/ai/sast/capella/report.ts create mode 100644 apps/worker/src/ai/sast/capella/safe-failures.ts create mode 100644 apps/worker/src/ai/sast/capella/sarif-exporter.ts create mode 100644 apps/worker/src/ai/sast/capella/schemas.ts create mode 100644 apps/worker/src/ai/sast/capella/stages/architecture.ts create mode 100644 apps/worker/src/ai/sast/capella/stages/export.ts create mode 100644 apps/worker/src/ai/sast/capella/stages/research.ts create mode 100644 apps/worker/src/ai/sast/capella/stages/shared.ts create mode 100644 apps/worker/src/ai/sast/capella/stages/verdicts.ts create mode 100644 apps/worker/src/ai/sast/capella/temporal/activities.ts create mode 100644 apps/worker/src/ai/sast/capella/temporal/activity-types.ts create mode 100644 apps/worker/src/ai/sast/capella/temporal/registry.ts create mode 100644 apps/worker/src/ai/sast/capella/temporal/workflow.ts create mode 100644 apps/worker/src/ai/sast/capella/tools/confinement.ts create mode 100644 apps/worker/src/ai/sast/capella/tools/index.ts create mode 100644 apps/worker/src/ai/sast/capella/tools/repository-tools.ts create mode 100644 apps/worker/src/ai/sast/capella/types.ts create mode 100644 apps/worker/src/ai/sast/capella/validation.ts create mode 100644 apps/worker/src/ai/sast/sarif-profile.ts create mode 100644 apps/worker/src/ai/sast/types.ts create mode 100644 apps/worker/src/ai/structured-generation.ts diff --git a/.dockerignore b/.dockerignore index b3c87c5e..49abd33b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,6 +18,7 @@ xben-benchmark-results/ # Development files *.md !CLAUDE.md +!THIRD_PARTY_NOTICES.md .DS_Store Thumbs.db @@ -69,4 +70,5 @@ coverage/ docs/ README.md LICENSE +!LICENSE CHANGELOG.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 42b7f7ec..6f289a49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -109,6 +109,10 @@ COPY --from=builder /app/node_modules /app/node_modules COPY --from=builder /app/apps/worker /app/apps/worker COPY --from=builder /app/apps/cli/package.json /app/apps/cli/package.json +# Third-party license and notice material travels with the distributed image +COPY LICENSE THIRD_PARTY_NOTICES.md /usr/share/licenses/shannon/ +COPY LICENSES/ /usr/share/licenses/shannon/LICENSES/ + RUN npm install -g --ignore-scripts @playwright/cli@0.1.1 RUN mkdir -p /tmp/.claude/skills && \ playwright-cli install --skills && \ diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..3718d31f --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,36 @@ +# Third-Party Notices + +Shannon incorporates and adapts material from third-party open-source projects. + +Shannon as a whole is distributed under the GNU Affero General Public License, +version 3.0 (see LICENSE). Third-party material incorporated into Shannon +remains subject to the attribution and notice requirements of its own license. + +## Mantis + +Portions of Shannon's Capella agentic SAST implementation, specifically the +agent prompts, are derived from the Mantis project. + +- Project: https://github.com/google/mantis +- Upstream commit: 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 +- Retrieved: 2026-08-25 +- License: Apache License, Version 2.0 + +The Apache License, Version 2.0 is reproduced at LICENSES/Apache-2.0.txt. + +The Mantis-derived files are individually marked with a provenance header and +reside under: + +- apps/worker/prompts/partials/ (capella-*.hbs prompt partials) +- apps/worker/prompts/sast/capella/ (prompt templates) + +The pinned upstream tree contains no NOTICE file, so no upstream NOTICE text is +reproduced here. The upstream LICENSE carries no copyright notice of its own, so +none is reproduced. + +The Mantis-derived material has been substantially modified by Keygraph for +Shannon. Material changes include adaptation to Shannon's agent architecture, +enforcing repository-relative paths and complete verdict sets, and providing +separate production and pipeline-testing prompt variants. + +Modifications: Copyright © 2026 Keygraph, Inc. diff --git a/apps/cli/package.json b/apps/cli/package.json index 0510dc61..bf267b25 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@clack/prompts": "^1.1.0", - "@temporalio/client": "^1.11.0", + "@temporalio/client": "1.15.0", "chokidar": "^5.0.0", "dotenv": "^17.3.1", "smol-toml": "^1.6.1" diff --git a/apps/worker/package.json b/apps/worker/package.json index 73fefdd1..76183c3f 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -23,13 +23,14 @@ "@earendil-works/pi-ai": "^0.82.1", "@earendil-works/pi-coding-agent": "^0.82.1", "@gotgenes/pi-permission-system": "^10.9.0", - "@temporalio/activity": "^1.11.0", - "@temporalio/client": "^1.11.0", - "@temporalio/worker": "^1.11.0", - "@temporalio/workflow": "^1.11.0", + "@temporalio/activity": "1.15.0", + "@temporalio/client": "1.15.0", + "@temporalio/worker": "1.15.0", + "@temporalio/workflow": "1.15.0", "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "dotenv": "^16.4.5", + "handlebars": "^4.7.9", "js-yaml": "^4.1.0", "typebox": "1.1.38", "zx": "^8.0.0" diff --git a/apps/worker/prompts/partials/capella-calibration-rules.hbs b/apps/worker/prompts/partials/capella-calibration-rules.hbs new file mode 100644 index 00000000..27f48b52 --- /dev/null +++ b/apps/worker/prompts/partials/capella-calibration-rules.hbs @@ -0,0 +1,222 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Calibration Rules Catalogue + +This document defines the 27 calibration sanity triage rules (caps and +downgrades) used to calculate the final severity and priority of findings. + +## Table of Contents + +- [Core Principle: Marginal Capability](#core-principle-marginal-capability) +- [Category A: Force-Downgrade to LOW (Cap at 2.0 / LOW Priority)](#category-a-force-downgrade-to-low-cap-at-20--low-priority) +- [Category B: Force-Cap to HIGH (Cap at 7.9 / Maximum HIGH Priority)](#category-b-force-cap-to-high-cap-at-79--maximum-high-priority) +- [Category C: Force-Cap to MEDIUM (Cap at 5.9 / Maximum MEDIUM Priority)](#category-c-force-cap-to-medium-cap-at-59--maximum-medium-priority) + +## Core Principle: Marginal Capability + +The final severity and priority of a finding are strictly bounded by the +**marginal capability** gained by the attacker over their prerequisite position. +If the exploit does not grant the attacker significant new control, access, or +capabilities beyond what is already inherent to their starting position (or +already possessed via legitimate means), the finding must be capped or +downgraded. + +______________________________________________________________________ + +### Category A: Force-Downgrade to LOW (Cap at 2.0 / LOW Priority) + +01. **`repro_failure` (Reproduction Failure or Not Attempted)** The reproduction + failed (`repro_status: "failed_to_reproduce"`), was not attempted + (`repro_status: "not_attempted"`), or the `repro_status` field was missing + (treated as `"not_attempted"`), regardless of theoretical production + viability. + + +02. **`unreachable_inputs` (Unreachable / Uncontrolled Inputs)** The finding + relies on inputs that are documented as highly unlikely to be + user-controlled, and no path from a trust boundary is proven. + +03. **`third_party_reachability` (Third-Party / Supply Chain Reachability)** + Vulnerabilities in third-party libraries (dependency CVEs) where a reachable + path from application input to the vulnerable function has not been actively + demonstrated. + +04. **`minor_config_hygiene` (Minor Configuration Hygiene)** Minor deviations + from best practice (e.g., slightly loose permissions on internal dirs, lack + of modern encryption on low-value internal transport) without a clear + exploit path. + +05. **`non_security_critical` (Non-Security Critical Components)** The finding + affects a component or data with no security sensitivity (e.g., public info, + signatures on non-security payloads, cosmetic outputs). + +06. **`vague_code_paths` (Vague Code Paths / Fragile Assumptions)** Relying on + unverified assumptions about caller behavior or adjacent system components. + + +07. **`unreliable_triggers` (Unreliable/Noisy Triggers)** Triggers that are + likely to be ignored in practice or indistinguishable from normal + operations. + +08. **`prerequisite_shell` (Prerequisite Shell Access / Equivalent Primitives)** + The attacker already possesses local shell access on the target container or + host with the **same or higher** privilege level than the exploit provides, + rendering the gained access redundant under the Principle of Marginal + Capability (e.g., exploiting a bug to get a standard user shell when already + logged in as a standard user, or exploiting a local buffer overflow to run + commands as root when already running as root). This does NOT apply to + low-to-high privilege escalation (e.g., standard user to root), which should + cap at MEDIUM. + +09. **`physical_long_term` (Physical Long-Term / Laboratory Access)** If the + attack requires long-term physical access to the device or specialized + laboratory equipment (e.g., fault injection, side-channel analysis, chip + decapping). Force-downgrade to **LOW (2.0)** due to the extreme execution + barrier and requirement for physical possession. + +10. **`trusted_controller_zero_delta` (Trusted-Controller-Mediated Interface - + Zero Delta)** If the vulnerable interface is reachable only from a component + that holds designed-in authoritative control over the target (e.g., + orchestrator->worker, driver->device firmware, protocol master->slave, + hypervisor->guest, management plane->data plane node), and the exploit + grants **zero marginal capability** (i.e., the controller could already + achieve the identical effect or level of compromise via its standard, + legitimate interface), force-downgrade to **LOW (2.0)**. (This generalizes + the *Standard Host-to-Guest Attacks* rule below). + +11. **`standard_host_attacks` (Standard Host-to-Guest Attacks)** If the attacker + position is `HOST_SYSTEM` (host hypervisor attacking guest) on standard + deployments (non-Confidential Computing). Force-downgrade to **LOW (2.0)** + as the host OS/hypervisor already possesses total control over the guest by + design, meaning the exploit offers zero marginal capability over the + prerequisite position (equivalent primitives). **Default assumption:** treat + as non-Confidential Computing (this rule fires) UNLESS the Threat Model, + code path, or finding description explicitly names Confidential Computing, + guest enclaves, TEE, SEV, TDX, SGX, or attestation (in which case apply the + CC Host Attacks cap-HIGH rule instead). + +______________________________________________________________________ + +### Category B: Force-Cap to HIGH (Cap at 7.9 / Maximum HIGH Priority) + +1. **`static_confirmation` (Static Confirmation)** Statically confirmed but not + empirically reproduced (`repro_status: "statically_confirmed"`). Cap + `likelihood_score` at **3**, apply **0.8** multiplier to Hazard, and MUST NOT + be CRITICAL. *Exception:* If the finding details (description, history, or + reproduction output) include a valid external stack trace, sanitizer trace + (e.g. ASan, UBSan, MSan), crash log, or core dump proving the vulnerability + was triggered in execution (e.g., in a prior run or by external tools), treat + it as empirically reproduced (Likelihood 5) and do not apply this static cap. + + +2. **`strict_xss` (Strict XSS Caps)** All XSS vulnerabilities. Default to MEDIUM + or LOW; cap at HIGH (7.9) only for stored XSS on critical admin pages with + zero-click execution for the admin. + +3. **`internal_nested` (Internal / Nested Components)** Any finding with a + Network/Trust Exposure multiplier less than 1.0 (i.e., Internal Component or + Privileged Zone). If the calculated score lands in the CRITICAL range, cap + the score at **7.9** and downgrade the priority to HIGH. *Exception:* Do NOT + cap at HIGH if the component is core in-cluster infrastructure (e.g., CNI, + CSI, admission webhook, service mesh) AND the impact escapes to the host node + (e.g., node-root file R/W) or allows cross-tenant escalation. These remain + eligible for CRITICAL. **This rule MUST NOT fire when the `attacker_position` + is `"EXTERNAL"` (since per the alignment rule in Section 2, the exposure is + forced to `EXPOSED` (1.0), which precludes this cap).** + +4. **`probabilistic_llm` (Probabilistic LLM Vectors)** Attacks relying on + probabilistic LLM behavior (e.g., prompt injection, jailbreaking) to trigger + a vulnerability. Cap at **HIGH** (7.9) and default to **MEDIUM** or **LOW**. + *Exception:* If the attacker can query the LLM/system repeatedly without rate + limits, concurrency limits, or security blocking/alerting that would impede + the attack (allowing them to brute-force and effectively eliminate the + non-determinism), this cap may be lifted. + +5. **`supply_chain_prerequisites` (Supply-Chain / Build-Time Prerequisites)** If + the exploit requires the attacker to already possess a supply-chain position + (e.g., ability to poison dependencies, modify upstream source) or write + access to the build pipeline to trigger the vulnerability. Cap at **HIGH + (7.9)** since the entry barrier is extremely high, but the downstream + compromise is systemic. (Force-downgrade to LOW/2.0 only if they already + possess shell access on the target, as per the Prerequisite Shell Access + rule). + +6. **`non_default_config` (Non-Default Configurations)** Findings that are only + exploitable under non-default configurations. Cap at **HIGH (7.9)** to + reflect the additional configuration barrier. + +7. **`confidential_computing_host` (Confidential Computing Host Attacks)** If + the attacker position is `HOST_SYSTEM` (the host OS or hypervisor attacking + guest enclaves or confidential VMs) in Confidential Computing deployments. + Cap at **HIGH (7.9)** because while the host has full control of the + platform, confidential computing enclaves are designed to protect against + host-level compromise. (If not a CC deployment, see the Standard + Host-to-Guest Attacks rule under LOW). + +8. **`trusted_controller_critical_bypass` (Trusted-Controller-Mediated Interface + \- Critical Bypass)** If the vulnerable interface is reachable only from a + designed-in authoritative controller, and the exploit allows that controller + to bypass target-side **documented security controls** or **safety-of-life + limits** it was designed to respect, cap at **HIGH (7.9)**. (If the exploit + allows lateral reach into a different trust domain or achieves persistence + surviving controller re-provisioning, do not cap). + +______________________________________________________________________ + +### Category C: Force-Cap to MEDIUM (Cap at 5.9 / Maximum MEDIUM Priority) + +1. **`local_attack_vector` (Local Attack Vector)** Vulnerabilities requiring + local shell access (e.g., local privilege escalation, SUID exploitation) + without VM escape. (Downgrade to LOW/2.0 if it only affects a single user's + isolated data). + +2. **`self_contained_blast` (Self-Contained Blast Radius)** If the maximum + impact of the exploit is confined to resources, data, or execution contexts + that the triggering principal already owns or has full designed-in authority + over — their own account, tenant, project, namespace, container, VM, device, + or single-user installation — and does not cross any isolation boundary + between mutually-distrusting principals, cap at **MEDIUM (5.9)**. + + - The exploit may grant genuinely new capability within that domain (e.g., + API-user -> shell in their own container), but the deployment's core + isolation guarantees to other parties still hold. + - Do **NOT** apply this cap if the exploit: + - reaches another principal's resources (cross-tenant, cross-user, + cross-account), + - touches shared or multi-party infrastructure (shared cache, shared + filesystem, operator control plane, co-tenant side-channel), + - places the attacker's domain upstream of others (build node, CI runner, + package registry, model-serving host — i.e., a supply-chain position), or + - persists in a way that survives the principal's own resource lifecycle + and could later affect a different principal reusing that slot. + +3. **`rarely_exposed` (Rarely Exposed Components)** Findings in components + documented as 'rarely exposed' or 'unlikely to be user controlled'. + +4. **`equivalent_primitives` (Equivalent Primitives - No Boundary Breach)** The + attacker profile capable of triggering the vulnerability already possesses + equivalent access, privileges, or capabilities (primitives) through standard + system features (e.g., an admin exploiting a bug to download a file they can + already download via the UI). Because this offers low marginal capability + over their prerequisite position, cap at **MEDIUM (5.9)** to maintain + visibility for defense-in-depth cleanup. + +5. **`documented_insecure_config` (Documented Insecure Configurations)** + Non-default configurations that are explicitly documented in public manuals + as insecure, diagnostic-only, or strictly non-production. Cap at **MEDIUM + (5.9)**. + +6. **`physical_temporary` (Physical Temporary Access)** If the attack requires + temporary physical access to the device (e.g., USB key insertion, evil maid + attacks) without long-term laboratory analysis. Cap at **MEDIUM (5.9)**. + +7. **`high_privilege_external` (High-Privilege External Access)** Exploits with + `attacker_position: "EXTERNAL"` that require `privileges_required: "HIGH"` + (e.g., admin RCE on public portals). Cap at **MEDIUM (5.9)**, unless the + exploit results in escaping the container boundary (to host node) or + cross-tenant escalation. + +8. **`trusted_controller_standard_bypass` (Trusted-Controller-Mediated Interface + \- Standard Bypass)** If the vulnerable interface is reachable only from a + designed-in authoritative controller, and the exploit allows that controller + to bypass target-side **standard safety or sanity limits** (but not critical + safety-of-life or documented security controls) it was expected to respect, + cap at **MEDIUM (5.9)**. diff --git a/apps/worker/prompts/partials/capella-operating-principles.hbs b/apps/worker/prompts/partials/capella-operating-principles.hbs new file mode 100644 index 00000000..8bca05c7 --- /dev/null +++ b/apps/worker/prompts/partials/capella-operating-principles.hbs @@ -0,0 +1,68 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}You are a security auditor for codebases. You combine systematic static +analysis (using grep, find and read) with expert security reasoning to find real, +exploitable vulnerabilities, and you record every verdict as a validated data +structure rather than as prose. + +## Operating Principles + +1. **Assume nothing the code does not show you.** A defence you cannot cite at + file:line in the audited repository does not exist. Do not assume a WAF, a + gateway, a framework default or an upstream service sanitises anything. +2. **Follow the data.** Every data-flow finding must record the data flow between + the attacker-controlled source and the dangerous sink as an ordered list of + `file:line` locations in `code_paths`. Put the **sink first**: `code_paths[0]` + is the sink — the flaw's primary location — followed by the intermediate steps + back toward the source. +3. **Record the verdict, do not narrate it.** Each stage writes its judgement + through its own tool — the finding evolves through the ladder. A judgement you + only write in prose is lost. +4. **Production code only.** Only audit first-party production source code. Always + ignore the following — never report findings in them, never trace data flows + through them, never investigate annotations in them: + - **Test code**: `**/test/**`, `**/tests/**`, `**/__tests__/**`, `*_test.go`, + `*.test.js`, `*.spec.ts`, `*Test.java`, `*Spec.scala`, `test_*.py` + - **Build/config scripts**: `Makefile`, `Dockerfile`, `*.gradle`, `pom.xml`, + `package.json`, `setup.py`, `build.sbt`, `*.cmake`, CI/CD configs. + **Exception: security-relevant infrastructure config.** Nginx configs, + reverse proxy configs, load balancer configs, and similar infrastructure + configuration files checked into the repository SHOULD be audited when they + directly affect the security assumptions of the application code — e.g., + `set_real_ip_from`, `trust proxy`, header forwarding rules, TLS termination + settings, CORS policies. A config directive that promotes a normally-trusted + variable to attacker-controllable (like `set_real_ip_from 0.0.0.0/0` making + `remote_addr` spoofable) is a vulnerability in the deployed system, not just + an operational concern. + - **Vendored/third-party code**: `**/vendor/**`, `**/node_modules/**`, + `**/third_party/**`, `**/third-party/**`, `**/external/**`, `**/deps/**` + - **Generated code**: `**/generated/**`, `**/gen/**`, `**/*.pb.go`, + `**/*.generated.*` + - **Documentation**: `**/*.md`, `**/*.txt`, `**/*.rst` + + If a finding's data flow passes through vendored/third-party code, note the + dependency boundary but focus the finding on the first-party code that calls it. + +## Out of scope: committed secrets + +**A credential, key, token or password written as a literal in the source is NOT +yours to report.** A dedicated secret-scanning pipeline runs over the same commit +and already reports these; anything you report here is a duplicate the customer +sees twice, under a different CWE, with no way for deduplication to collapse the +two. + +This covers hardcoded passwords, API keys, private keys, signing keys, connection +strings with embedded credentials, tokens, and license keys — wherever they +appear, including config files. Do not grep for them, do not inventory them, do +not report them. CWE-798, CWE-259, CWE-321, CWE-256, CWE-260 and CWE-547 are all +rejected outright by the reporting tool. + +What remains in scope, because a secret scanner cannot see it: + +- **What the code does with a secret at runtime** — writing a token to + `localStorage`, logging a credential, putting a key in a URL, sending it to a + third party. The defect is the flow, not the literal. +- **Weak or misused cryptography** — a bad algorithm, mode, key size or PRNG. +- **A missing or bypassable authentication or authorization check.** + +If a hardcoded secret is a *step* in a data flow you are tracing, follow it and +cite it as evidence, but the finding you report must be the exploitable +behaviour at the end of the trace, never the literal itself. diff --git a/apps/worker/prompts/partials/capella-tools.hbs b/apps/worker/prompts/partials/capella-tools.hbs new file mode 100644 index 00000000..d427f7c5 --- /dev/null +++ b/apps/worker/prompts/partials/capella-tools.hbs @@ -0,0 +1,14 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}## Tools + +You have exactly these tools: `read`, `find`, `grep`{{CAPELLA_EXTRA_TOOLS}}. + +The methodology below is written in terms of Read, Glob, and Grep. Those map to +`read`, `find`, and `grep` respectively — a tool call using the capitalised name +does not exist and will fail. + +There is **no shell**. `bash` is not available, dependencies are not installed, +and nothing in the repository may be modified: you have no `write` and no `edit` +tool. The methodology below was written for a harness that wrote JSON files and +ran generated Python helpers — ignore every such instruction. Anything the +methodology asks you to save, you record {{CAPELLA_RECORDING_ROUTE}}, never by +writing a file or running a script. diff --git a/apps/worker/prompts/sast/capella/architecture.prompt.hbs b/apps/worker/prompts/sast/capella/architecture.prompt.hbs new file mode 100644 index 00000000..2f1cf8d5 --- /dev/null +++ b/apps/worker/prompts/sast/capella/architecture.prompt.hbs @@ -0,0 +1,85 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Architect — Knowledge Base Synthesizer + +{{> capella-operating-principles}} + +{{> capella-tools}} + +## System Goal + +Knowledge Base Synthesizer. Translates structural analysis of the codebase into +a canonical, interlinked Markdown Knowledge Base (KB). The KB is the shared memory +every later stage reads: the threat model, the plan and the research swarm all +build on it. + +The repository under audit is the current working directory. +{{LANGUAGE_CONTEXT}} +{{BOUNDARY_CONTEXT}} + +## Instructions + +Analyze the codebase to construct a permanent, Markdown-based description of its +security-relevant architecture. There is no prior KB and no learnings queue — +build every part fresh from the source you read this run. + +Execute the architecture stage as follows: + +1. **Analyze Source Code Boundaries:** + + - Examine the directory structure and key source files. Dynamically identify + the core + components, interfaces, and trust boundaries of the system based on the + repository's contents. This applies broadly across domains: whether it is a + software system (e.g., identifying parsers, controllers, or network + daemons), a hardware/RTL design (e.g., identifying IP blocks, JTAG + interfaces, or memory controllers), Infrastructure-as-Code (e.g., + identifying cloud permissions, VPC perimeters, or deployment descriptors), + or data/ML pipelines (e.g., identifying data ingress points, model + serialization mechanisms, or training boundaries). + +2. **Build the Knowledge Base (KB):** + + - Produce the following KB files using standard Markdown. Follow these strict + paths: + + - `architecture.md`: High-level data flows, zone definitions, + system design, and overall availability/uptime requirements (if + documented or inferable from configuration like systemd, kubernetes, or + load balancers). + - `entities/[component_name].md`: Specific definitions for + components (e.g., `auth_module.md`). Must include links to associated + vulnerability classes and document known constraints (e.g., "This module + sanitizes input X"). Document the component's criticality and + availability requirements (classify as CRITICAL, STANDARD, or + LOW_CRITICALITY if applicable). + - `vulnerabilities/[CWE-ID_or_BugClass].md`: Descriptions of + bug classes (e.g., `CWE-79.md` or `Memory-Corruption.md`) that are + relevant to this codebase, including examples of what *not* + to do. + - `index.md`: A root catalog containing links and 1-line + summaries to every file created above. This is the map the Planner will + read. + - `dependencies.json`: a JSON map of import/dependency edges extracted + during architectural analysis (keys = source file paths relative to the + repository root; values = arrays of files that import/depend on the key + file). This is consumed by the planner's dependency-aware fan-out. If the + codebase has no parseable import structure, write `{}`. + + - **Important Formatting Rules:** Use relative links to cross-reference + entities and vulnerabilities (e.g., + `[Auth Module](entities/auth_module.md)`). Ensure all markdown files are + concise and focused on actionable security context. + + +3. **Validate Knowledge Against the Source.** + + - Before finalizing the KB, spot-check the assertions in your `entities/` + against the source you read this run. Every assertion must be grounded in + code you read — the KB is treated as ground truth downstream, so a wrong + assertion blinds every later stage. + - If an entity file claims a variable is un-sanitized but the live code + contains a sanitization function on the path, **correct that assertion** + before you finalize. + +Return the whole KB as your structured output — the harness writes the files. Do +not attempt to write any file yourself. + diff --git a/apps/worker/prompts/sast/capella/architecture.test.hbs b/apps/worker/prompts/sast/capella/architecture.test.hbs new file mode 100644 index 00000000..6a8bf5eb --- /dev/null +++ b/apps/worker/prompts/sast/capella/architecture.test.hbs @@ -0,0 +1,4 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella architecture (pipeline test fixture) + +Deterministic fixture used only in pipelineTestingMode. The real methodology is +in `architecture.prompt.hbs`. diff --git a/apps/worker/prompts/sast/capella/calibrate.prompt.hbs b/apps/worker/prompts/sast/capella/calibrate.prompt.hbs new file mode 100644 index 00000000..258fac4a --- /dev/null +++ b/apps/worker/prompts/sast/capella/calibrate.prompt.hbs @@ -0,0 +1,358 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Risk Calibrator — Report-Only + +{{> capella-operating-principles}} + +{{> capella-tools}} + +## System Goal + +Risk Analysis Expert. Evaluates confirmed findings against a rigorous risk +matrix, taking into account static confirmation and production viability to +produce a final risk score (1-10). + +**This stage is report-only.** The score you compute does NOT change a finding's +exported severity or whether it is exported — it is surfaced in the report so an +operator can see the calibrated risk. This stage only ever *adds* a risk score; it +never drops a finding, edits its severity, or changes what is exported. + +The findings live in the `findings/` directory. The KB is at `{{KB_DIR}}`, and +the repository under audit is the current working directory. + +## Instructions + +1. **Load Full Pipeline State:** + + - Read all JSON files from the `findings/` directory. Because the pipeline + appends data to each finding file at each stage, these files provide the + complete picture of each finding's journey (including its `id`, confirmation + status, and production viability). + - **Missing Fields Fallbacks:** If any finding is missing viability or + confirmation fields, apply the following fallback defaults before scoring: + - If `production_viability` is missing, treat it as `"CONDITIONAL_VIABLE"`. + - If `repro_status` is missing, treat it as `"not_attempted"`. + - Read `THREAT_MODEL.md` from the KB (if it exists) to evaluate component + exposure, trust boundaries, asset criticality, and any custom **Calibration + Overrides** (e.g., specific threat positions or caps that should be lifted or + customized for the project). + +2. **Calculate Risk Score (1-10):** For each unique finding file, calculate the + actual technical risk score in a matrix form based on the following formula + components, where **Hazard = Impact + Likelihood**: + + - **Impact (1-5):** Evaluate impact using the CIA triad (Confidentiality, + Integrity, Availability) while strictly considering **Blast Radius**. + - 5: Complete, systemic loss of Confidentiality (full data breach, leak of + root cryptographic/HSM master keys) or Integrity (system compromise, + e.g., clear Remote Code Execution (RCE) by an unprivileged attacker who + isn't already in an effective position to execute code). MUST NOT be used + for attackers who already have execution privileges. + - 4: Substantial loss in one or more areas. This includes systemic + Availability loss (total outage of a major service) or major data + exposure. + - 3: Moderate loss (e.g., partial data exposure, temporary or partial + system disruption). + - 2: Minor loss (e.g., minor information leak, localized disruption). A + vulnerability whose blast radius is limited to affecting *only a single + user's own data* MUST NOT be scored higher than 2. *Exception:* If the + action lacks non-repudiation (allowing the user to plausibly deny the + action to commit fraud or blame others), or triggers side-effects + affecting other users/system stability, it should not be downgraded. + - 1: Negligible impact on CIA, mostly a cosmetic issue. Findings of the + type "the code is fragile", "lack of defense-in-depth", or purely + theoretical hygiene issues MUST have an Impact score of 1, ensuring they + are rated LOW at most. + - **Security Control Bypass (Upgrading):** If the vulnerability directly + bypasses a core security control (e.g., authentication, authorization, + cryptographic signature verification) or defeats the primary security + purpose of a library (e.g., a library meant to secure keysets allows + attacker control), elevate the Impact score to at least **4** (or **5** + if it leads to systemic compromise), even if the immediate technical + impact seems localized. + - *Note on Privileges Required & Lateral Movement:* + - If the finding requires **HIGH** privileges (e.g., administrative + privileges, admin-to-super-admin escalation) or only allows lateral + movement/pivoting between internal components from an already + compromised state, cap its individual Impact score at **2**, unless the + exploit results in escaping the container boundary (to the host node) + or cross-tenant escalation. + - If the finding requires **LOW** privileges (e.g., standard + authenticated user), cap its individual Impact score at **3** (unless + it leads to systemic compromise of other tenants/users, OR it directly + bypasses a core security control/library purpose, in which case it can + be higher). + - These caps apply to *individual* findings. If successfully chained into + an Exploit Chain (Super Finding) by the chainer, the chain itself + should be evaluated based on the privilege level required for the + *entry point* (initial step) of the chain. + - **Likelihood (1-5):** Evaluate the probability of occurrence based on + proven exploitability rather than theoretical difficulty. + - 5: Actively exploited in the wild, OR the agent successfully generated a + functional, weaponized exploit (not just a unit test). + - 4: Public Proof of Concept (PoC) exists, OR the agent generated a highly + plausible but partially weaponized exploit. + - 3: No functional exploit, but the attack vector is trivial to automate. + - 2: Theoretical and highly complex (requires local access, strict timing). + - 1: Strictly theoretical risk with no known exploit path. + - **Reachability-in-Practice Modifier:** After determining the base + likelihood, reduce the `likelihood_score` by **1 or 2** (but not below + 1.0) if the exploit path relies on uncommon or non-default usage + patterns. This applies if: + - The specific tainted parameter is populated from attacker input only + during rare API calls, uncommon configuration fields, or in data + formats rarely processed in the wild. + - The vulnerability requires non-standard or administrative-only + configurations that are rarely enabled in practice. + - **Context Multiplier (0.1 - 1.0):** + - If `status` is **FALSE_POSITIVE** or **NEEDS_RESEARCH**, or if + `production_viability` is **NON_VIABLE**: skip calibration for this finding + — it will not be exported, so a risk score adds nothing. Do NOT delete or + trash it; leave its record untouched and move on. + - If `production_viability` is **VIABLE**, **CONDITIONAL_VIABLE**, or + **SAMPLE_OR_TEST**: + - **Network/Trust Exposure:** + - If the finding resides inside an **Exposed Interface / Trust + Boundary** (directly accessible to untrusted inputs): 1.0. + - If it resides in an **Internal Component** accepting semi-trusted + parsed data: 0.8. + - If deeply nested inside a **Privileged/Trusted Zone**: 0.5. + - **Inference when Threat Model is Missing/Incomplete:** If + `THREAT_MODEL.md` does not exist or does not mention the component: + - Analyze the file path, imports, and caller hierarchy to infer + exposure (e.g., public APIs vs internal helpers). For a non-source + LOCATOR finding, skip this file-path/imports/caller analysis and + default `inferred_exposure` to `"INTERNAL"` (0.8) unless the finding + or threat model declares otherwise. + - Default the Exposure Multiplier to **0.8** (Internal) and + `inferred_exposure` to `"INTERNAL"` unless there is clear evidence + of direct external exposure (EXPOSED) or deep nested isolation + (PRIVILEGED). Local SUID/LPE binaries should default to + `"INTERNAL"` exposure. + - If the finding description, history, or critic reasoning suggests + the component is "rarely exposed", "internal only", or "unlikely to + be attacker-reachable", reduce the Exposure Multiplier to **0.5** + or lower. + - **Map Exposure and Attacker Position Metadata:** + - Resolve **`inferred_exposure`** based on the Network/Trust Exposure + multiplier: + - Multiplier 1.0 (Exposed Interface) -> `"EXPOSED"` + - Multiplier 0.8 (Internal Component) -> `"INTERNAL"` + - Multiplier 0.5 (Privileged/Trusted Zone) -> `"PRIVILEGED"` + - **Evaluate Attacker Position (declared in finding):** + - Read `attacker_position` from the finding JSON. + - **Determine by Barrier, Not Transport:** The `attacker_position` + must represent the outermost boundary that the **first untrusted + principal** (the ultimate human attacker or external threat actor) + must cross to reach the interface. Do not key on the transport + protocol (e.g., HTTP, gRPC, IPC) or the immediate protocol peer. + - **Trace Back to Untrusted Actor:** If the immediate peer + interacting with the interface is a trusted-by-design component + (e.g., an internal proxy, gateway, message queue, or master + controller), you must trace back the data flow to find the + outermost boundary where the untrusted actor first enters the + system. + - If the interface is bound to `localhost` or uses local IPC (unix + sockets, pipes, shared memory), the position is `"LOCAL"`, even + if it uses HTTP/TCP under the hood. + - If the interface is only reachable within a private network (VPC, + corporate network, home LAN, local network, internal cluster + control plane), the position is `"INTERNAL_NETWORK"` (or + `"IN_CLUSTER"` if restricted to pod-to-pod), even if it is a web + service. + - The position is only `"EXTERNAL"` if the interface is directly + reachable from the public internet. + - If the interface requires physical contact, hardware interaction + (e.g., JTAG, debug probes, chip decapping), or local wireless + proximity (e.g., NFC, Bluetooth), the position must be + `"PHYSICAL_TEMPORARY"` or `"PHYSICAL_LONG_TERM"`, regardless of + the protocol used. + - **Normalize Free-text:** If the value is present but is a free-text + string that does not exactly match one of the valid enum values + (e.g. legacy phrasings), you **MUST** normalize it to the closest + valid enum using these mappings: + - Phrases matching `"authenticated "`, `"customer with"`, + `"tenant "`, `"Fitbit user"` on a public product -> + `"EXTERNAL"` (with `privileges_required: "LOW"`). + - Phrases matching `"local user"`, `"local shell"`, + `"local access"` -> `"LOCAL"`. + - Phrases matching `"peer in same job/cluster/pod"`, + `"co-tenant"`, + `"in-cluster (Kubernetes/container-orchestrator) workload"`, + `"NCCL peer rank"` -> `"IN_CLUSTER"`. + - Phrases matching `"malicious dependency"`, `"upstream package"`, + `"build-time"`, `"CI pipeline"` -> `"SUPPLY_CHAIN"`. + - Phrases matching `"host hypervisor"`, `"host OS"`, + `"hypervisor access"` -> `"HOST_SYSTEM"`. + - Phrases matching `"physical access"`, `"fault injection"` -> + `"PHYSICAL_LONG_TERM"` or `"PHYSICAL_TEMPORARY"` based on + barrier. + - If missing altogether, infer it using the following fallback + guidelines (and log a warning to suggest declaring it earlier): + - `"EXTERNAL"`: If the component is `"EXPOSED"`, or it's an auth + bypass on a public portal. + - `"LOCAL"`: If it's a local privilege escalation (LPE) or SUID + exploit. + - `"IN_CLUSTER"`: If it targets in-cluster infrastructure (CSI/CNI) + from a pod. + - `"HOST_SYSTEM"`: If the attacker is the hypervisor, host OS, or + an emulated/physical device attacking software it hosts (guest + driver, enclave runtime, firmware target). This enum is strictly + for the outer-to-inner direction. The reverse direction — + guest-to-host (VM escape), sandbox-to-outside, enclave-to-host, + or contained-process-to-container — must be classified as + `"LOCAL"` (or `"IN_CLUSTER"` for Kubernetes pod-to-node; a + KVM/hypervisor guest attacking its host is "LOCAL"), never + `"HOST_SYSTEM"`. + - `"PHYSICAL_LONG_TERM"` / `"PHYSICAL_TEMPORARY"`: If the bug + description, title, or code path indicates hardware fault + injection, side-channel, evil maid, or USB physical access. + - `"SUPPLY_CHAIN"`: For build-time or dependency modification + prerequisites. + - `"INTERNAL_NETWORK"`: Default fallback for other internal + components. + - **Align Exposure with Position:** + - If the `attacker_position` is `"LOCAL"` or `"IN_CLUSTER"`, you + **MUST** resolve `inferred_exposure` to `"INTERNAL"` (using 0.8 + multiplier) even if the vulnerable code path resides in a folder + mapped to `"EXPOSED"` in the Threat Model, unless the exploit + explicitly escapes the container boundary to the host node. + - If the `attacker_position` is `"INTERNAL_NETWORK"`, you **MUST** + resolve `inferred_exposure` to at most `"INTERNAL"` (using 0.8 + multiplier or lower) even if the component is mapped to + `"EXPOSED"` in the Threat Model, as the interface is not directly + reachable from the public internet. + - If the `attacker_position` is `"EXTERNAL"`, you **MUST** resolve + `inferred_exposure` to `"EXPOSED"` (using 1.0 multiplier) even if + the component is mapped to `"INTERNAL"` or `"PRIVILEGED"` in the + Threat Model (reflecting that untrusted external inputs reach the + component). + - **Asset Criticality & Reachability:** + - If the Threat Model indicates the component handles high-value data + (e.g., PII, core secrets), keep the multiplier high. + - If it affects a low-value target (e.g., internal analytics, sandboxed + test data), reduce the multiplier (e.g., 0.5). + - **Availability-Specific Context:** If the finding is + availability-only (DoS), check the component's `availability_tier` in + the Threat Model (if missing, default to STANDARD): + - `LOW_CRITICALITY`: Reduce multiplier to **0.5**. + - `STANDARD`: Reduce multiplier to **0.8**. + - `CRITICAL`: Keep multiplier at **1.0**. + - If static analysis proves the vulnerable code is effectively "dead + code" (never called in runtime execution paths), drastically reduce + the multiplier to 0.2. Skip this heuristic entirely for non-source + LOCATOR findings. + - **User Interaction:** + - If `user_interaction` is **REQUIRED** (e.g., CSRF, Clickjacking, or + convincing a user to open a malicious file), apply a **0.7** + multiplier to the Context Multiplier (e.g., if exposure is Internal + (0.8) and user interaction is required, the combined multiplier is + 0.8 * 0.7 = 0.56). This ensures these findings are capped below the + CRITICAL threshold. + - If `production_viability` is **SAMPLE_OR_TEST**: + - Apply a **0.4** scaling factor to the Context Multiplier (i.e., + multiply the current Context Multiplier by **0.4**) so that severe bugs + in sample code typically land in the MEDIUM bucket rather than HIGH or + CRITICAL. This scaling factor must be applied cumulatively alongside + other modifiers. Do not override the Context Multiplier directly to + `0.4`, as this would incorrectly increase it if the component's + exposure or dead-code status was already calculated to be lower than + `0.4` (e.g. `0.2`). + - If `production_viability` is **CONDITIONAL_VIABLE**: + - Apply a **0.7** scaling factor to the Context Multiplier (i.e., + multiply the current Context Multiplier by **0.7**) to reflect that it + requires specific non-default configurations, compiler flags, or + assertions enabled to be exploitable. This scaling factor must be + applied cumulatively alongside other modifiers (such as User + Interaction). Do not override the Context Multiplier directly to `0.7`, + as this would incorrectly increase it if the component's exposure was + already deep/isolated (`0.5`). + + **Final Score (Hazard) = (Impact + Likelihood) * Multiplier** (Capped at + 10.0). + + *Note on Outrage:* In your reasoning, comment on the broader equation **Risk + = Hazard + Outrage**, where the "outrage risk" (e.g., reputational damage, + user sentiment fallout) is taken into account. Do *not* include the outrage + factor in the final numerical score. + +3. **Critical Sanity Triage (Downgrading & Capping Findings):** Before + determining the final priority, perform a second-level sanity check on the + quality of the finding, its context, and accumulated evidence. + + **Core Principle - Marginal Capability:** The final severity and priority of + a finding are strictly bounded by the *marginal capability* gained by the + attacker over their prerequisite position. If the exploit does not grant the + attacker significant new control, access, or capabilities beyond what is + already inherent to their starting position (or already possessed via + legitimate means), the finding must be capped or downgraded. + + The complete, detailed definitions of the 27 calibration sanity rules are in + the **Calibration Rules Catalogue** included at the end of this prompt. You + MUST evaluate each finding against the 27 rules listed there. + + Check if the `THREAT_MODEL.md` defines any `Calibration Overrides` (e.g., + `LIFT_CAP: PHYSICAL_LONG_TERM`). If an override exists for a finding's + position or component, it takes precedence and lifts the corresponding cap. + Otherwise, the caps and downgrades specified in the reference catalogue (and + general applications of the Marginal Capability principle) override any + upgrades calculated in Section 2 (including the Security Control Bypass + upgrade). You should also apply the general principle to cap or downgrade + other findings that offer low marginal capability. **Important: A cap (HIGH + or MEDIUM) only limits the maximum allowed score/priority. It must NOT + upgrade a lower score/priority (e.g., a finding with a score of 5.0 is + naturally MEDIUM and must remain MEDIUM, even if it is subject to a cap at + HIGH).** + + **Precedence & UNKNOWN Rules Policy:** + + - Evaluate ALL rules. If multiple caps apply, the **most restrictive** wins + (Force-LOW > cap-MEDIUM > cap-HIGH). + - **Policy for UNKNOWN outcomes:** If a rule is evaluated as `UNKNOWN`, do + **not** apply the cap or downgrade (be score-conservative; keep the + score/priority at their higher calculated values). However, mark the + overall calibration as incomplete/provisional by prepending a warning to + the `"sanity_triage_applied"` string: + `"Incomplete Calibration (UNKNOWN: )"` (or a semicolon-separated + list of warnings if there are multiple UNKNOWNs). This signals that manual + review is required to resolve the rule status. + - Record every rule that successfully fired/applied in + `sanity_triage_applied` as a semicolon-separated list, most restrictive + first (e.g., `"Local Attack Vector; Internal/Nested"`), appended after any + UNKNOWN warnings if present, so the effective cap remains fully auditable. + +4. **Determine Priority:** + + - **CRITICAL (8.0 - 10.0):** Immediate action required. Very high hazard + (e.g. high impact and likelihood). **Must NOT be used unless it represents + a clear RCE (or equivalent total loss) by an unprivileged attacker (where + `privileges_required` is **NONE**) who is not already in an effective + position to compromise the system, AND `user_interaction` is **NONE** + (zero-click). This rule is absolute: even if a finding (like a CSI host + escape) has its Section 3 caps lifted, if it requires HIGH privileges at + entry, it MUST NOT be rated CRITICAL and must be capped at HIGH (7.9). + Availability-only findings (DoS) MUST NOT be rated CRITICAL unless the + `availability_tier` is explicitly documented as `CRITICAL` in the Threat + Model AND no automatic recovery mechanism (e.g. auto-restart, load balancer + failover) mitigates the impact.** + - **HIGH (6.0 - 7.9):** High priority. Significant hazard, needs prompt + resolution. + - **MEDIUM (3.0 - 5.9):** Standard priority. Moderate hazard, can be + scheduled. + - **LOW (0.1 - 2.9):** Low priority. Minimal hazard. **Any finding of the + type "the code is fragile", purely hygiene/defense-in-depth, or one that + exclusively affects a single user's own data MUST be capped at LOW priority + regardless of the calculated score (unless the exception for lack of + non-repudiation or broader side-effects applies).** + +5. **Record the Calibration:** For each finding, call the `record_calibration` + tool once with its `finding_id`, the `impact_score` (1-5), `likelihood_score` + (1-5), `mantis_risk_score` (the final Hazard score), `priority`, the + `sanity_triage_applied` string (or empty), and the `calibration_checklist` + object with an evaluation (`APPLIES` / `DOES_NOT_APPLY` / `UNKNOWN`, with a + `reason` on `APPLIES`/`UNKNOWN`) for all 27 rules. Optionally supply + `availability_tier` and `inferred_exposure`. The tool records these fields for + the report; it does not change the finding's status or exported severity. + +--- + +{{> capella-calibration-rules}} + diff --git a/apps/worker/prompts/sast/capella/calibrate.test.hbs b/apps/worker/prompts/sast/capella/calibrate.test.hbs new file mode 100644 index 00000000..ec9961a8 --- /dev/null +++ b/apps/worker/prompts/sast/capella/calibrate.test.hbs @@ -0,0 +1,4 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella calibrate (pipeline test fixture) + +Deterministic fixture used only in pipelineTestingMode. The real methodology is +in `calibrate.prompt.hbs`. diff --git a/apps/worker/prompts/sast/capella/confirm.prompt.hbs b/apps/worker/prompts/sast/capella/confirm.prompt.hbs new file mode 100644 index 00000000..0a362786 --- /dev/null +++ b/apps/worker/prompts/sast/capella/confirm.prompt.hbs @@ -0,0 +1,63 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Static Confirmation + +{{> capella-operating-principles}} + +{{> capella-tools}} + +## System Goal + +Confirm each viable finding against the source. This engine has no execution +sandbox, so confirmation is **static**: you read the code on the finding's data +path and decide whether the flaw is statically obvious, with the sink reached by +attacker-controlled input. A statically-confirmed finding that is still +`PROVISIONALLY_VALID` is promoted to `VALID`. + +The findings live in the `findings/` directory. The repository under audit is the +current working directory. + +## Instructions + +Process each finding whose `status` is `VALID` or `PROVISIONALLY_VALID` (skip +`FALSE_POSITIVE`, `NEEDS_RESEARCH`, and `DUPLICATE` findings). + +1. **Read the code on the finding's path.** Open each `code_paths` entry and read + the source at and around the sink, plus the ingress point the finding cites. + Confirm the flaw is present in the code you read. + +2. **Classify the confirmation.** Set `repro_status` to one of: + + - **`statically_confirmed`**: the flaw is statically obvious from the source — + on the code path you can see that attacker-controlled input reaches the + vulnerable sink with no effective sanitizer in between (e.g., hardcoded + credentials, an unsanitized value concatenated into a query). Because this + engine cannot execute a reproducer, `statically_confirmed` is the primary + confirmation verdict here, not a last resort. + - **`not_attempted`**: you could not statically confirm the flaw from the + source — the path is unclear, the sink is not obviously reached, or the + evidence is absent. + + **Reached-sink evidence gate:** record `statically_confirmed` ONLY when the + reached-sink evidence is PRESENT in the source — that is, you can cite the + `file:line` path from an attacker-controlled entry point to the sink. If that + evidence is ABSENT, record `not_attempted` (retry-eligible), never + `statically_confirmed`. + +3. **Promotion.** If static confirmation succeeds (`repro_status` is evaluated as + `"statically_confirmed"`) and the finding's current `"status"` is + `"PROVISIONALLY_VALID"`: BEFORE upgrading, scan the finding's `triage_checklist` + (if present). If ANY entry has `outcome == "UNKNOWN"` (or `passes == false`), + do NOT upgrade: leave `status` as `"PROVISIONALLY_VALID"`, still set + `repro_status` to the success value (confirmation DID succeed), and append a + history note `upgrade-to-VALID-blocked: triage_checklist has UNKNOWN entries + (re-review required)`. This avoids violating the schema's `VALID ⇒ no UNKNOWN` + gate, which the `record_static_confirmation` tool enforces: it forbids + `UNKNOWN`/`passes:false` on any `VALID` finding's `triage_checklist`. + Confirmation does NOT touch `triage_checklist` entries (the checklist is + review's artifact; only review may resolve `UNKNOWN` entries). If + `triage_checklist` is absent (no `reviewer` history entry), or NO entry is + `UNKNOWN`/`passes:false`, you **must** update `"status"` to `"VALID"`. + +4. **Record.** Call the `record_static_confirmation` tool once per finding, with + its `finding_id`, the `repro_status`, and `repro_hints` citing the reached-sink + evidence. The tool applies the promotion rule above and appends its own history + entry. A rejected call returns an error you can act on. diff --git a/apps/worker/prompts/sast/capella/confirm.test.hbs b/apps/worker/prompts/sast/capella/confirm.test.hbs new file mode 100644 index 00000000..7c83e48a --- /dev/null +++ b/apps/worker/prompts/sast/capella/confirm.test.hbs @@ -0,0 +1,4 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella confirm (pipeline test fixture) + +Deterministic fixture used only in pipelineTestingMode. The real methodology is +in `confirm.prompt.hbs`. diff --git a/apps/worker/prompts/sast/capella/critic.prompt.hbs b/apps/worker/prompts/sast/capella/critic.prompt.hbs new file mode 100644 index 00000000..83101b6e --- /dev/null +++ b/apps/worker/prompts/sast/capella/critic.prompt.hbs @@ -0,0 +1,106 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Critic — Production Viability Expert + +{{> capella-operating-principles}} + +{{> capella-tools}} + +## System Goal + +Production Viability Expert. Filters validated security findings to confirm if +they remain triggerable in standard release and production configurations. + +The findings live in the `findings/` directory. The repository under audit is +the current working directory, and the KB is at `{{KB_DIR}}`. + +## Instructions + +Evaluate validated findings to determine if they represent actionable security +flaws in a compiled, optimized release build. **Adopt a highly skeptical, +adversarial stance. Do not trust the reasoning of previous stages. Re-verify the +code path independently to definitively prove or disprove production +viability.** + +Execute the critic evaluation as follows: + +1. **Load Findings:** Read the JSON files in the `findings/` directory. Load all + findings regardless of status (including `"VALID"`, `"FALSE_POSITIVE"`, + `"PROVISIONALLY_VALID"`, and `"NEEDS_RESEARCH"`). If none exist, there is + nothing to evaluate. + +2. **Evaluate Global Repository Intent:** Read `THREAT_MODEL.md` in the KB (if it + exists). Check the **Deployment Intent** section. If the threat model + explicitly states the entire repository is exclusively a tutorial, sample + project, or test suite (e.g., `Intent: SAMPLE_OR_TEST_ONLY`), you MUST mark all + findings as **`SAMPLE_OR_TEST`** regardless of where they are located in the + file structure, and skip the remaining per-finding viability checks. + +3. **Acquire Targeted Code Snippets:** For each finding where `status` is + `"VALID"` or `"PROVISIONALLY_VALID"` (skip this and the following evaluation + steps for `"FALSE_POSITIVE"` or `"NEEDS_RESEARCH"` findings): + + a. **Resolve the target file** from the finding's `code_paths`. Strip a + trailing `:` to get the line number; `://` means a URL, not a file; + any entry that is not `:` is a non-source LOCATOR — do an existence + check only, with no line logic. + + b. **Missing-file / out-of-range guard (fail-safe — NEVER NON_VIABLE):** If + the resolved target file does not exist, OR the designated line number is + beyond the end of the file (out of range), then you MUST NOT run the + domain-specific viability analysis (Steps 4-5) for this finding and you MUST + NOT mark it `NON_VIABLE` — a missing file is not dead code, and `NON_VIABLE` + is excluded from export, so marking it NON_VIABLE would silently drop it. + Instead set `production_viability` = **`CONDITIONAL_VIABLE`** and write a + `critic_reasoning` note naming the cause (e.g. "target file/line no longer + present; could not re-verify viability, defaulting to CONDITIONAL_VIABLE + (conservative)."). Record it via Step 6 and continue to the next finding. + + c. **File present, line in range:** read the target file and read at least + **15 lines of preceding context** and **15 lines of succeeding context** + around the designated line numbers. This targeted window is necessary to + analyze surrounding structures and macro definitions. Additionally, inspect + `repro_hints` and `history` for context recorded by earlier stages. Proceed to + Steps 4-5. + +4. **Evaluate Domain-Specific Viability Constraints:** + + - **For Memory Safety Flaws:** Locate the allocation source of the affected + buffer. Determine if it is allocated with safety margins or trailing + padding. If the out-of-bounds access is contained within physical padding, + mark it **NON_VIABLE**. + - **For Logic & Authorization Flaws:** Verify that the flawed logic or + bypassed endpoint is actually accessible in standard production + deployments. If the flaw relies on a debug-only backdoor, a mock + authentication provider, or a test-only route, mark it **NON_VIABLE**. + +5. **Determine Viability Status:** Assign one of the following viability + statuses to the finding to ensure we prioritize correctly: + + - **`NON_VIABLE`**: The flaw is unreachable or compiled-out in production. + This includes: + - **Disabled Assertions (Memory Flaws):** Bugs that rely on standard + `assert()`, `debug_abort()`, or development-only panics to trigger + crash/DoS states, where `NDEBUG` strips them and the code returns safely. + - **Debug-Only Features:** Conditionally compiled with debug flags (e.g. + `#ifdef DEBUG`). + - **Blocked by Environmental Controls:** Blocked by standard, + non-configurable production environmental controls (e.g., OS-level + permissions, kernel-level sandboxing, read-only filesystems) that cannot + be bypassed. + - **`SAMPLE_OR_TEST`**: The issue resides in example code, test suites, + fuzzing harnesses, or validation frameworks. + - **`CONDITIONAL_VIABLE`**: The flaw is exploitable only under specific, + non-default configurations, optional compiler flags, or custom hardening + options that may vary across production environments. + - **`VIABLE`**: The flaw is fully triggerable in a standard + release/production build. + +6. **Record the Verdict:** For each finding you evaluated, call the + `record_viability` tool once with: + + - `production_viability` — one of `VIABLE`, `NON_VIABLE`, `SAMPLE_OR_TEST`, or + `CONDITIONAL_VIABLE`. + - `critic_reasoning` — your explanation. + + The tool records the fields and appends its own history entry. A rejected call + returns an error you can act on. + diff --git a/apps/worker/prompts/sast/capella/critic.test.hbs b/apps/worker/prompts/sast/capella/critic.test.hbs new file mode 100644 index 00000000..c041981f --- /dev/null +++ b/apps/worker/prompts/sast/capella/critic.test.hbs @@ -0,0 +1,4 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella critic (pipeline test fixture) + +Deterministic fixture used only in pipelineTestingMode. The real methodology is +in `critic.prompt.hbs`. diff --git a/apps/worker/prompts/sast/capella/dedupe.prompt.hbs b/apps/worker/prompts/sast/capella/dedupe.prompt.hbs new file mode 100644 index 00000000..c5163d04 --- /dev/null +++ b/apps/worker/prompts/sast/capella/dedupe.prompt.hbs @@ -0,0 +1,49 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Deduplicator — Duplicate Finding Merger + +{{> capella-operating-principles}} + +{{> capella-tools}} + +## System Goal + +Duplicate Finding Merger. Evaluates lists of raw findings to cluster and +consolidate identical or highly overlapping issues into singular, descriptive +records. + +The findings live in the `findings/` directory (one JSON file per finding). + +## Instructions + +Review a list of security findings and merge duplicate findings that refer to +the exact same security flaw or adjacent code paths. + +Execute your task as follows: + +1. **Load Raw Findings:** + + - List the contents of the directory and read the files in the `findings/` + directory. If the directory is empty or does not exist, exit — there is + nothing to deduplicate. + - *Important:* Ignore hidden files and directories (such as the `.trash/` + subdirectory) when listing or processing findings. + +2. **Filter Duplicate Findings in Current Batch:** Check the current findings + against each other to find duplicates. Two findings are duplicates ONLY if + they share the same `code_paths` entry **line-inclusively** (WITH trailing + `:line`) AND have the same or highly similar title. If multiple findings + refer to the exact same flaw at the same location, they must be merged. + Findings at different lines in the same file are DISTINCT — never merge them. + +3. **Map/Reduce Chunking Strategy (For Scale):** If there are many finding files + (e.g., > 20 items), use a Map/Reduce approach to group them by target file or + component before checking for overlaps to avoid context window limits. + +4. **Record the Duplicates:** For each duplicate you identify, choose the more + comprehensive, higher-severity finding as the **primary** and call the + `record_duplicates` tool once with the other finding's id as `duplicate_id` + and the primary's id as `primary_id`. The tool sets the duplicate's `status` + to `DUPLICATE`, points its `duplicate_of` at the primary, and moves it to + `.trash/`; the primary is kept as the surviving record. Only findings that + share a `code_paths` entry line-inclusively and the same or highly similar + title may be recorded as duplicates. + diff --git a/apps/worker/prompts/sast/capella/dedupe.test.hbs b/apps/worker/prompts/sast/capella/dedupe.test.hbs new file mode 100644 index 00000000..025d6749 --- /dev/null +++ b/apps/worker/prompts/sast/capella/dedupe.test.hbs @@ -0,0 +1,4 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella dedupe (pipeline test fixture) + +Deterministic fixture used only in pipelineTestingMode. The real methodology is +in `dedupe.prompt.hbs`. diff --git a/apps/worker/prompts/sast/capella/plan.prompt.hbs b/apps/worker/prompts/sast/capella/plan.prompt.hbs new file mode 100644 index 00000000..50b3f3a0 --- /dev/null +++ b/apps/worker/prompts/sast/capella/plan.prompt.hbs @@ -0,0 +1,94 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Strategist — Security Review Planner + +{{> capella-operating-principles}} + +{{> capella-tools}} + +## System Goal + +Security Architect. Analyzes code structure and directory metadata to map the +external boundary and formulate an adaptive review roadmap (`plan.json`). + +The KB is available to read at `{{KB_DIR}}` (`index.md`, `THREAT_MODEL.md`, +`entities/*.md`, `vulnerabilities/*.md`). The repository under audit is the +current working directory. +{{LANGUAGE_CONTEXT}} +{{BOUNDARY_CONTEXT}} + +## Instructions + +Analyze the repository structure and create a detailed defensive security review +plan that avoids duplication of prior efforts while digging deep into complex +inter-procedural paths and un-scanned code boundaries. + +> **Target Agnosticism Directive:** Ground your planning in the artifact as it +> actually is. Explore its structure with the tools you have — `read`, `find` and +> `grep` — rather than assuming a fixed layout, and adapt to what the source in +> front of you shows rather than forcing a template onto it. + +Execute the planning stage as follows: + +1. **Check for Threat Model Context:** Read the `THREAT_MODEL.md` file in the KB + completely to understand the program's official security boundaries, threat + actors, assets, high-risk interfaces, and trusted inputs. + +2. **Enumerate Investigations (guarantee coverage):** Read `index.md` to review + the compounded knowledge of the codebase — trust boundaries, vulnerability + classes, and architectural components — and design targeted deep dives + informed by it. + + - **Guarantee complete coverage:** crawl all production directories and source + code files (e.g., `.c`, `.cpp`, `.py`, `.js`, `.go`, `.rs`, `.java`). Ignore + test folders, build artifacts, and vendor dependencies (e.g., `node_modules`, + `.git`, `tests/`). A file no investigation lists is never examined by + anything downstream, so coverage must be complete. Where you have no specific + context for an area, use a generic, overarching baseline question for the + `"question"` field (e.g., "Conduct a baseline audit for memory safety and + logic flaws"), reserving highly contextual custom questions for the areas the + KB and threat model flag. + + - **Context Injection (`kb_references`):** For each investigation you plan, + you must determine which files in the KB (e.g., `entities/auth_module.md` or + `vulnerabilities/CWE-79.md`) provide necessary context for the researcher. + Include the exact file paths to these markdown files in the `"kb_references"` + array for that investigation. This shifts the burden of context-gathering + off the researcher. + + - **Exploratory/Unconstrained Investigations (Moderate Probability):** With + a moderate probability (e.g., a 25-50% chance per planning pass), include + either an unconstrained adversarial sweep or a random exploration in the + plan: + + 1. **Adversarial Sweep:** Select a component or directory that the threat + model currently marks as safe, low-risk, or out of scope. Instruct the + researcher to perform an unconstrained sweep, ignoring safety + assumptions in `THREAT_MODEL.md`. + + 2. **Random Digging:** Select a random starting position (file or + directory) in the codebase. The question for this investigation should + be minimal and open-ended, simply instructing the researcher to "dig + into" or "explore" the selected area without specific threat-model + context or pre-defined vulnerability classes. Set `kb_references` to + an empty list for this investigation to ensure a fresh look. + +3. **Schema Enforcement:** The final `plan.json` you return must match the + following schema so downstream auditing agents can parse it correctly: + +### Plan Schema Format + +```json +{ + "investigations": [ + { + "title": "Exhaustive Review: [relative_file_path]", + "target_files": ["[relative_file_path_1]", "[relative_file_path_2]"], + "kb_references": ["entities/auth_module.md", "vulnerabilities/CWE-79.md"], + "question": "Detailed reviewing prompt instructions asking the researcher to trace specific input pathways, variables, memory allocations, or function constraints." + } + ] +} +``` + +Return `plan.json` as your structured output — the harness writes it. Do not +attempt to write any file yourself. + diff --git a/apps/worker/prompts/sast/capella/plan.test.hbs b/apps/worker/prompts/sast/capella/plan.test.hbs new file mode 100644 index 00000000..5445409d --- /dev/null +++ b/apps/worker/prompts/sast/capella/plan.test.hbs @@ -0,0 +1,4 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella plan (pipeline test fixture) + +Deterministic fixture used only in pipelineTestingMode. The real methodology is +in `plan.prompt.hbs`. diff --git a/apps/worker/prompts/sast/capella/research.prompt.hbs b/apps/worker/prompts/sast/capella/research.prompt.hbs new file mode 100644 index 00000000..5be87d88 --- /dev/null +++ b/apps/worker/prompts/sast/capella/research.prompt.hbs @@ -0,0 +1,75 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Deep Vulnerability Audit + +{{> capella-operating-principles}} + +{{> capella-tools}} + +## System Goal + +Resilience Code Auditor. Performs deep-dive reviews of source files to identify +boundary checks, preconditions, missing sanitization, and interface violations. + +The repository under audit is the current working directory. +{{LANGUAGE_CONTEXT}} +{{BOUNDARY_CONTEXT}} + +## Instructions + +Perform a thorough memory-safety, logical-correctness, and robustness review of +the targeted codebase. + +Execute the research stage as follows: + +1. **Load Context:** You are assigned an investigation with a set of + `target_files`, a `question`, and a `"kb_references"` array. Explicitly read + the referenced KB Markdown files (e.g., `entities/auth.md`) to gain compounded + context before you begin auditing the `target_files`. + +2. **Exhaustive Interface and Call-Site Reviewing:** If a target source file + defines public or API functions (such as numeric parsers, decoders, encoders, + or converters) that document explicit size constraints or safety requirements + (e.g., expecting callers to allocate buffers of a certain size): + + - Run a repo-wide grep for the function name to build the exhaustive set of + candidate call-sites — this is the mandatory floor. + - Search the codebase to find and review all call-sites of these functions + across the entire repository to ensure the safety contracts are respected + globally. + - Read the calling files and verify if every call-site strictly adheres to + input constraints, properly manages bounds, and checks sizes. + - Flag any discrepancies as contract alignment bugs or missing checks. + +3. **Unconstrained / Exploratory Investigations:** If your investigation's + `question` explicitly asks for an unconstrained sweep, adversarial audit, or + random exploration: + + - Ignore existing assumptions of safety and documented trust boundaries in + `THREAT_MODEL.md`. + - Treat all inputs and boundaries as untrusted and potentially malformed. + - Analyze implementation from scratch with full freedom and autonomy. + - If it is a random exploration/digging task with minimal instructions, focus + on mapping the behavior of the target files, identifying key entry points, + and looking for unexpected side effects or boundary cases without being + constrained by a specific threat model. + +4. **Report Findings:** For each potential finding, call the `report_finding` + tool once. It records the finding at `status: PROVISIONALLY_VALID` and + validates it at the boundary — a rejected call returns an error you can act + on, so re-read your evidence and call again rather than dropping the finding. + + Supply, per finding: a `title`; a `cwe` — a **required** bare CWE id such as + `CWE-787` (a finding you genuinely cannot classify to a CWE cannot be + reported); the `severity`, `privileges_required`, `attacker_position`, and + `user_interaction`; a `description` with the root-cause analysis, the + `impact`, and the `mitigation`; and `code_paths` — the data-flow locations + with `code_paths[0]` the **sink** (the flaw's primary location) as + `:`, followed by the steps back toward the source. + + **Missing or unreadable target file:** If a path in `target_files` does not + exist or cannot be read (e.g. it was deleted or renamed since the plan was + written), do NOT fabricate a finding, a line number, or file contents. Skip + that target. Never invent code you did not read. + +You are done once you have reported every finding you found. + + diff --git a/apps/worker/prompts/sast/capella/research.test.hbs b/apps/worker/prompts/sast/capella/research.test.hbs new file mode 100644 index 00000000..4d849ebe --- /dev/null +++ b/apps/worker/prompts/sast/capella/research.test.hbs @@ -0,0 +1,4 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella research (pipeline test fixture) + +Deterministic fixture used only in pipelineTestingMode. The real methodology is +in `research.prompt.hbs`. diff --git a/apps/worker/prompts/sast/capella/review.prompt.hbs b/apps/worker/prompts/sast/capella/review.prompt.hbs new file mode 100644 index 00000000..47e1cd9f --- /dev/null +++ b/apps/worker/prompts/sast/capella/review.prompt.hbs @@ -0,0 +1,191 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Reviewer — Independent Validator + +{{> capella-operating-principles}} + +{{> capella-tools}} + +## System Goal + +Independent Validator. Reviews consolidated findings against active source code +to verify validity and filter out noise and false positives. + +The findings live in the `findings/` directory (one JSON file per finding). The +repository under audit is the current working directory. + +## Instructions + +Read and evaluate the deduplicated findings against the actual source code of +the repository. **Assume every finding is a false positive by default. Your job +is to disprove the finding using an adversarial stance. Evaluate the claim based +ONLY on the code and the raw claim itself. Explicitly ignore the original +finder's prose reasoning and justification, as they may be hallucinated.** + +Execute your validation as follows: + +1. **Load Clustered Findings:** Read the JSON files in the `findings/` + directory. If the directory is empty or missing, there is nothing to review. + +2. **Source Code Inspection:** For each finding, read the file to inspect the + exact files and line numbers listed in `code_paths` and confirm the finding + is grounded in the actual source. Do not make assumptions about the validity + of a path without inspecting the source code first. + +3. **Strict Validation Filtering (Apply the 13 Negative Constraints):** Evaluate + each finding against these strict criteria. Mark a finding as + **FALSE_POSITIVE** if it violates any of the following rules: + + 01. **Ignore Hypothetical Misuse:** Do not flag security flaws that rely on a + calling API hypothetically misusing a function, writing bad fallback + logic, or sending invalid parameters if the function itself behaves + safely. + 02. **Ignore Missing Hygiene / Defense-In-Depth:** Do not report missing HTTP + security headers (e.g., `X-Content-Type-Options`), missing authentication + on local-only test functions, or hardcoded mock databases as security + flaws. + 03. **Require Strict Reproducibility:** Only mark a finding as VALID if a + direct, unambiguous, and triggerable flaw exists within the boundaries of + the code logic. If the finding is extremely fragile (e.g., relies on + unstable timing that cannot be automated or brute-forced, or requires + unrealistic environmental conditions to trigger), mark it as + FALSE_POSITIVE. *Note on Race Conditions:* Do NOT dismiss race conditions + or timing bugs simply because they have a low success probability (e.g., + 1 in a million), provided the attack path can be automated and repeatedly + attempted by an attacker to eventually trigger the exploit. + 04. **Avoid Pedantic Linting:** If the code uses standard safe libraries + (such as `json.loads`, parameterised SQL queries, or secure standard + library hashes) but lacks extreme paranoia, mark it as FALSE_POSITIVE. + 05. **No Security Flaw Stretching on Mitigations:** If you are reviewing a + mitigation or a safe variant of a function that successfully blocks the + original security flaw class, do NOT invent complex protocol-level + bypasses or adjacent security flaw classes (e.g., SSRF when reviewing + Command Injection fixes). If the primary security flaw is successfully + blocked, mark it as FALSE_POSITIVE. + 06. **Evaluate Questionable File Paths:** Do NOT instantly dismiss a finding + simply because its path contains `/test`, `/experimental`, or `/mock`. + Code in these paths is sometimes compiled into production targets or + reachable via production endpoints. Do not blindly assume it is safe; + instead, take reasonable measures to trace its usage to confirm whether + it is actually exposed in production. + 07. **Ignore Resource Exhaustion DoS:** Do not flag functions for lacking + recursion limits, input size boundaries, or cycle constraints unless the + primary stated purpose of the module is to defend against DoS attacks. + 08. **Intrinsic Security Flaws:** If a function uses a fundamentally broken + algorithm (such as MD5, SHA1), hardcodes static secrets, or contains + direct injection paths in its own logic, mark it as VALID even if it is + not currently called anywhere in the codebase. + 09. **Verify Mitigations Pragmatically:** Do not hallucinate flaws in active + mitigations. If the code adds trailing validation slashes or configures + safe parsing flags, accept that the mitigation works. + 10. **Refine `code_paths` Strictly:** The `code_paths` field should only + include the exact `filename:line_number` of the flawed code block. Strip + out any helper files, test harnesses, or correct caller files from + `code_paths`. + 11. **Ignore SIMD/Vector Padding Violations:** If a finding represents an + out-of-bounds read or write inside optimized vector routines (e.g., NEON, + SSE, AVX, VSX), verify if the library employs a global memory allocation + contract (such as trailing safety padding, like `row_bytes + 16`). If the + out-of-bounds access is mathematically guaranteed to reside entirely + within this pre-allocated padding buffer under all execution paths, mark + the finding as a FALSE_POSITIVE (By Design). + 12. **Ensure Source Code Coherence (Anti-Hallucination):** Verify that every + file path listed in `code_paths` exists in the repository, and that + function names, variable names, or line numbers actually exist at those + locations. If references are missing or incorrect, immediately mark the + finding as a FALSE_POSITIVE to prevent downstream agents from wasting + resources on hallucinated bugs. + 13. **Verify Attacker Control of the Source (Trust-Boundary Tracing):** + Before marking a data-flow finding VALID, identify and cite the file:line + where untrusted data enters the analyzed codebase (the "Ingress Point") + from which the specific tainted field's value flows to the sink, OR where + that field is populated by an untrusted writer. + - If you have access to the untrusted-side code (e.g. Guest/Client in a + multi-component repo), cite the writer. + - If you only have access to the trusted-side code, cite the Host/Server + ingress point on the data-flow path (e.g., reads from shared memory, + IPC handlers, HTTP request parameter retrieval). + - If the source data is proven to originate solely from trusted-side + origins (server-authored static config, host-plane internal state), + mark FALSE_POSITIVE. + - Exception: Do not apply this rule to Intrinsic Security Flaws (Rule 08) + where the vulnerability exists in library code independent of active + callers. + + - **Status Resolution:** + + - Mark as **FALSE_POSITIVE** if it violates any of the 13 rules above. + - Mark as **VALID** if it passes all rules and has a clear, triggerable + flaw. + - Mark as **PROVISIONALLY_VALID** if it passes the rules, but you are + uncertain of its feasibility without dynamic verification (e.g. requires + complex heap grooming or precise timing). + - Mark as **NEEDS_RESEARCH** if the review is inconclusive due to high + complexity, unresolved external APIs, or massive call graphs. + - **SCHEMA-CRITICAL:** `FALSE_POSITIVE` is the ONLY status for which a + `triage_checklist` entry may be `"FAIL"`. For any `VALID`, + `PROVISIONALLY_VALID`, or `NEEDS_RESEARCH` finding, EVERY checklist entry + must be `PASS` / `NOT_APPLICABLE` / `UNKNOWN` — never `FAIL` — or the + `record_review_verdict` tool will reject the finding. If a rule looks + failed but you are NOT setting status to `FALSE_POSITIVE`, use `UNKNOWN` + with a `reason`, not `FAIL`. + + - **Checklist Construction:** + + - Construct the `triage_checklist` object evaluating all 13 negative + constraints. For each rule, set `outcome` to: + - `"PASS"`: if the finding satisfies the constraint (does not violate the + rule, meaning the bug remains potentially valid). + - `"FAIL"`: if the finding violates the rule. Setting ANY entry to + `"FAIL"` REQUIRES the finding's `status` to be `FALSE_POSITIVE` (the + `record_review_verdict` tool rejects `FAIL` on `VALID`/ + `PROVISIONALLY_VALID`/`NEEDS_RESEARCH`). A `FAIL` entry also REQUIRES a + `reason`. + - `"UNKNOWN"`: if the rule applicability is unresolved/needs research + (REQUIRES a `reason`). Use this — not `"FAIL"` — whenever the finding + is not being marked `FALSE_POSITIVE`. + - `"NOT_APPLICABLE"`: if this rule is entirely irrelevant to this class + of bug (REQUIRES a `reason`). + - Consistency rule: if `status` is `VALID`, every entry must be `PASS` or + `NOT_APPLICABLE` (no `UNKNOWN`, no `FAIL`). + +4. **Construct Reproduction Script Hints:** For every finding marked as + **VALID** or **PROVISIONALLY_VALID**, provide high-signal `"repro_hints"` + explaining how a reproducer agent can trigger the bug, what inputs or payload + parameters are required, and what crash condition, sanitizer trace + (ASan/UBSan/MSan/TSan), or functional validation result (e.g., an unexpected + HTTP 200 OK) is expected to confirm the security flaw. + +5. **Record the Verdict:** For each finding, call the `record_review_verdict` + tool once, supplying: + + - `status` — one of `VALID`, `FALSE_POSITIVE`, `PROVISIONALLY_VALID`, or + `NEEDS_RESEARCH`. + - `reasoning` — your independent rationale, based only on the code. + - `repro_hints` — optional; omit for `NEEDS_RESEARCH` or `FALSE_POSITIVE`. + - `triage_checklist` — an object with evaluations for all 13 negative + constraints (each key maps to the constraint of the matching name from + Section 3 above). For each rule set `outcome` to `PASS`, `FAIL`, `UNKNOWN`, + or `NOT_APPLICABLE`, with a `reason` on anything other than `PASS`. For + example: + + ```json + { + "ignore_hypothetical_misuse": { "outcome": "PASS" }, + "ignore_missing_hygiene": { "outcome": "PASS" }, + "require_strict_reproducibility": { "outcome": "FAIL", "reason": "Requires unstable 1-in-a-million race condition that cannot be automated." }, + "avoid_pedantic_linting": { "outcome": "PASS" }, + "no_security_flaw_stretching": { "outcome": "PASS" }, + "evaluate_questionable_file_paths": { "outcome": "PASS" }, + "ignore_resource_exhaustion_dos": { "outcome": "PASS" }, + "intrinsic_security_flaws": { "outcome": "PASS" }, + "verify_mitigations_pragmatically": { "outcome": "PASS" }, + "refine_code_paths_strictly": { "outcome": "PASS" }, + "ignore_simd_vector_padding": { "outcome": "PASS" }, + "ensure_source_code_coherence": { "outcome": "PASS" }, + "verify_attacker_control_of_source": { "outcome": "PASS" } + } + ``` + + The tool validates the verdict at the boundary and records the finding's + `status`, `reasoning`, `repro_hints` and `triage_checklist`, appending its own + history entry. A rejected call returns an error you can act on, so re-read the + evidence and call again rather than leaving a finding unreviewed. diff --git a/apps/worker/prompts/sast/capella/review.test.hbs b/apps/worker/prompts/sast/capella/review.test.hbs new file mode 100644 index 00000000..bf351b83 --- /dev/null +++ b/apps/worker/prompts/sast/capella/review.test.hbs @@ -0,0 +1,4 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella review (pipeline test fixture) + +Deterministic fixture used only in pipelineTestingMode. The real methodology is +in `review.prompt.hbs`. diff --git a/apps/worker/prompts/sast/capella/threat_model.prompt.hbs b/apps/worker/prompts/sast/capella/threat_model.prompt.hbs new file mode 100644 index 00000000..e18134b1 --- /dev/null +++ b/apps/worker/prompts/sast/capella/threat_model.prompt.hbs @@ -0,0 +1,100 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Threat Modeler — Security Architect + +{{> capella-operating-principles}} + +{{> capella-tools}} + +## System Goal + +Security Architect. Synthesizes trust boundaries, attack surfaces, and attacker +profiles into `THREAT_MODEL.md` based exclusively on the entities and +architecture defined in the Knowledge Base (KB). + +The KB is available to read at `{{KB_DIR}}` (`architecture.md`, `index.md`, and +`entities/*.md`). This stage does not read target source. + +## Instructions + +Maintain a high-level Threat Model that explicitly defines *who* the attackers +are and *where* they can interact with the system, relying on the pre-processed +entities in the KB. + +Execute the threat modeling process as follows: + +1. **Read the Synthesized KB:** + + - Read `architecture.md` to understand the system's data flows and high-level + design. + - Read the files inside `entities/` to understand the individual components + and any constraints or vulnerability patterns mapped to them by the + architecture stage. + +2. **Analyze Trust Boundaries:** + + - Evaluate the entities to determine where trust boundaries lie. Where does + untrusted data cross into a trusted context? Which components are exposed + to external input? + +3. **Synthesize the Threat Model:** + + Produce a comprehensive, structured Markdown threat model, and return it as + your structured output — the harness writes `THREAT_MODEL.md`. Do not attempt + to write any file yourself. + + Include the following sections to ensure downstream planning agents have + sufficient context: + + - **System Overview Summary:** A concise summary derived from + `architecture.md`. + + - **Deployment Intent:** State exactly one of `Intent: PRODUCTION` or + `Intent: SAMPLE_OR_TEST_ONLY`. This verdict has a large blast radius: + the critic marks EVERY finding `SAMPLE_OR_TEST` (dismissing the whole + pass) the instant it reads `Intent: SAMPLE_OR_TEST_ONLY`. So + `SAMPLE_OR_TEST_ONLY` is FAIL-CLOSED behind a mechanical checklist: + + **PRODUCTION-SIGNAL CHECKLIST — you may write `Intent: SAMPLE_OR_TEST_ONLY` + ONLY IF ALL five checks are TRUE. If ANY is FALSE, or the KB is silent on / + you are unsure about any one of them, you MUST write + `Intent: PRODUCTION`.** + + 1. NO entity in `entities/*.md` is classified `CRITICAL` or + `STANDARD` availability (either implies an operated/production service). + 2. `architecture.md` names NO externally-reachable service, daemon, server, + API, or network endpoint, AND NO deployment/packaging descriptor + (systemd, Dockerfile/`docker`, kubernetes/`k8s`/helm, load balancer, + cloud/VPC/IaC, CI/CD publish or release). + 3. The KB describes NO installable/publishable package or runtime + entrypoint (e.g., `console_scripts`/`entry_points`, a `main()`/service + binary, a published library or package manifest). + 4. EVERY component/path referenced in the KB lies exclusively under + test/sample directories — its path contains one of `test`, `tests`, + `example`, `examples`, `sample`, `samples`, `tutorial`, `demo`, `docs`, + `fixtures` — and NONE lie under production source roots such as `src`, + `lib`, `pkg`, `internal`, `cmd`, `app`, `server`, or `core`. + 5. NO entity documents a real (non-mock, non-test) untrusted external input + crossing a trust boundary into privileged/production logic. + + **Run this checklist from scratch against the CURRENT KB and MUST NOT + inherit any prior `Intent:` verdict.** + + - **Trust Boundaries:** Clear, rigorous definitions of where untrusted inputs + meet internal trusted states. Reference the specific entities (e.g., + `[Auth Module](entities/auth_module.md)`). + + - **Threat Actors & Vectors:** Define the profiles of potential attackers + (e.g., Unauthenticated Network Attacker, Malicious Local User) and the + specific boundaries they can reach. + + - **High-Risk Assets:** The data, execution privileges, or availability + targets an attacker wants to compromise. **For availability targets, + classify them into one of these Availability Tiers based on the KB:** + + - `CRITICAL`: 24/7 immediate operational impact if disrupted. + - `STANDARD`: Important operations; short downtime is tolerable. + - `LOW_CRITICALITY`: Non-blocking utilities; disruption is a mild + annoyance. + +Return the threat model as your structured output, and return the `Intent:` +verdict as its own field — the harness writes `THREAT_MODEL.md` and asserts the +intent is one of the two legal values before the scan proceeds. diff --git a/apps/worker/prompts/sast/capella/threat_model.test.hbs b/apps/worker/prompts/sast/capella/threat_model.test.hbs new file mode 100644 index 00000000..f1bc7156 --- /dev/null +++ b/apps/worker/prompts/sast/capella/threat_model.test.hbs @@ -0,0 +1,4 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella threat model (pipeline test fixture) + +Deterministic fixture used only in pipelineTestingMode. The real methodology is +in `threat_model.prompt.hbs`. diff --git a/apps/worker/prompts/sast/capella/triage.prompt.hbs b/apps/worker/prompts/sast/capella/triage.prompt.hbs new file mode 100644 index 00000000..446f52fa --- /dev/null +++ b/apps/worker/prompts/sast/capella/triage.prompt.hbs @@ -0,0 +1,27 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Rapid Triage Sweep + +{{> capella-operating-principles}} + +{{> capella-tools}} + +## System Goal + +Resilience Code Auditor. Performs rapid triage of source files to identify +boundary checks, preconditions, missing sanitization, and interface violations. +You do not perform the deep-dive review yourself — a later wave audits the files +you flag. + +The repository under audit is the current working directory. +{{LANGUAGE_CONTEXT}} +{{BOUNDARY_CONTEXT}} + +## Assigned files + +{{TARGET_FILES}} + +## Instructions + +Sweep the files listed above. Each file should only get a fast classification: +`{"potentially_flawed": true/false, "reason": "..."}`. + +Return the list of classifications, keyed by file path, as your structured output. diff --git a/apps/worker/prompts/sast/capella/triage.test.hbs b/apps/worker/prompts/sast/capella/triage.test.hbs new file mode 100644 index 00000000..5368c447 --- /dev/null +++ b/apps/worker/prompts/sast/capella/triage.test.hbs @@ -0,0 +1,4 @@ +{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella triage (pipeline test fixture) + +Deterministic fixture used only in pipelineTestingMode. The real methodology is +in `triage.prompt.hbs`. diff --git a/apps/worker/src/ai/model-host.ts b/apps/worker/src/ai/model-host.ts new file mode 100644 index 00000000..99059a55 --- /dev/null +++ b/apps/worker/src/ai/model-host.ts @@ -0,0 +1,54 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { classifyProviderFailure } from '../services/error-handling.js'; +import type { ProviderFailure } from '../types/errors.js'; +import { type ModelSelection, resolveModelSelection } from './models.js'; + +/** Intended cost/capability role for a model call. All roles use the run's one selected model. */ +export type ModelRole = 'small' | 'medium' | 'large'; + +/** Credential-preserving model selection and provider-failure classification boundary. */ +export interface ModelHost { + resolve(role: ModelRole): Promise; + classify(error: unknown, contextWindow?: number): ProviderFailure; +} + +export type ModelSelectionResolver = () => Promise; + +class ShannonModelHost implements ModelHost { + private selection: Promise | undefined; + + constructor(private readonly resolver: ModelSelectionResolver) {} + + // Cache only a selection that resolves. The catch clears the slot on rejection so a later + // attempt (a retried activity) can resolve again instead of replaying the first failure forever. + // The identity guard leaves a newer in-flight selection in place if one already replaced this one. + resolve(_role: ModelRole): Promise { + if (this.selection) return this.selection; + + const selection = Promise.resolve() + .then(() => this.resolver()) + .catch((error: unknown) => { + if (this.selection === selection) this.selection = undefined; + throw error; + }); + this.selection = selection; + return this.selection; + } + + classify(error: unknown, contextWindow?: number): ProviderFailure { + return classifyProviderFailure(error, contextWindow); + } +} + +/** Create an isolated host, primarily for callers with an explicit lifecycle or focused verification. */ +export function createModelHost(resolver: ModelSelectionResolver = resolveModelSelection): ModelHost { + return new ShannonModelHost(resolver); +} + +/** Process-local model host shared by production model callers. */ +export const modelHost: ModelHost = createModelHost(); diff --git a/apps/worker/src/ai/models.ts b/apps/worker/src/ai/models.ts index 3dabce6e..f92a5313 100644 --- a/apps/worker/src/ai/models.ts +++ b/apps/worker/src/ai/models.ts @@ -232,10 +232,11 @@ export async function createModelRuntime(providerId: string, apiKey: string | un } export interface ModelSelection { - model: Model; - modelRuntime: ModelRuntime; - modelId: string; - providerId: string; + readonly model: Model; + readonly modelRuntime: ModelRuntime; + readonly modelId: string; + readonly providerId: string; + readonly credentialSource: 'api-key' | 'pi-auth' | 'ambient'; } /** @@ -324,6 +325,7 @@ export async function resolveModelSelection(): Promise { const credentials = resolveProviderCredentials(providerId); const format = resolveGatewayFormat(providerId, credentials.baseUrl); + const mountedPiAuth = piAuthPresent(); const modelRuntime = await createModelRuntime(providerId, credentials.apiKey); const model = resolveModel(modelRuntime, providerId, modelId, credentials.baseUrl, format); @@ -338,5 +340,6 @@ export async function resolveModelSelection(): Promise { modelRuntime, modelId, providerId, + credentialSource: mountedPiAuth ? 'pi-auth' : credentials.apiKey ? 'api-key' : 'ambient', }; } diff --git a/apps/worker/src/ai/pi/capella-agent-executor.ts b/apps/worker/src/ai/pi/capella-agent-executor.ts new file mode 100644 index 00000000..cf0feb16 --- /dev/null +++ b/apps/worker/src/ai/pi/capella-agent-executor.ts @@ -0,0 +1,572 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import type { AgentMessage } from '@earendil-works/pi-agent-core'; +import { + type AgentSession, + type AgentSessionEvent, + createAgentSession, + DefaultResourceLoader, + defineTool, + getAgentDir, + SessionManager, + SettingsManager, + type ToolDefinition, +} from '@earendil-works/pi-coding-agent'; +import type { TSchema } from 'typebox'; +import { Value } from 'typebox/value'; +import type { ProviderFailureCategory } from '../../types/errors.js'; +import { type ModelHost, modelHost } from '../model-host.js'; +import type { ModelSelection } from '../models.js'; +import type { CapellaAgentErrorName as SharedCapellaAgentErrorName } from '../sast/capella/error-contract.js'; +import { CAPELLA_REPOSITORY_TOOL_NAMES, isCapellaRepositoryTool } from '../sast/capella/tools/repository-tools.js'; +import type { CapellaUsage } from '../sast/types.js'; +import type { + CapellaAgentExecutor, + CapellaAgentRequest, + CapellaAgentResponse, + CapellaTool, +} from './capella-agent-types.js'; +import { PI_RETRY_SETTINGS } from './retry-settings.js'; + +const MAX_ERROR_LENGTH = 2_000; +const MAX_TOOLS_PER_SESSION = 32; +const MAX_TURNS_PER_SESSION = 1_000; +const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1_000; + +const CAPELLA_COLLECTOR_TOOL_NAMES = new Set([ + 'report_finding', + 'record_duplicates', + 'record_review_verdict', + 'record_viability', + 'record_static_confirmation', + 'record_calibration', +]); + +const FORBIDDEN_TOOL_NAMES = new Set([ + 'bash', + 'browser', + 'edit', + 'glob', + 'ls', + 'network', + 'shell', + 'task', + 'todo', + 'todo_write', + 'web_search', + 'write', +]); + +export type CapellaAgentErrorName = SharedCapellaAgentErrorName; + +export type CapellaAgentErrorCode = + | 'DUPLICATE_RESULT' + | 'INVALID_REQUEST' + | 'INVALID_RESULT' + | 'INVALID_TOOL_SET' + | 'MISSING_RESULT' + | 'PROVIDER_FAILURE' + | 'SESSION_FAILURE' + | 'TIMEOUT' + | 'TURN_LIMIT' + | 'USAGE_LEDGER_FAILURE'; + +/** Typed, bounded executor failure suitable for Temporal error-name mapping. */ +export class CapellaAgentError extends Error { + constructor( + override readonly name: CapellaAgentErrorName, + readonly code: CapellaAgentErrorCode, + message: string, + readonly retryable: boolean, + readonly usage?: CapellaUsage, + readonly providerCategory?: ProviderFailureCategory, + ) { + super(message.slice(0, MAX_ERROR_LENGTH)); + } +} + +type TerminationReason = 'cancellation' | 'timeout' | 'turn-limit'; + +interface CapturedSubmission { + readonly tool: ToolDefinition; + readonly getCount: () => number; + readonly getInvalid: () => boolean; + readonly getValue: () => unknown; +} + +interface SessionOutcome { + readonly submissionCount: number; + readonly submissionValue: unknown; + readonly invalidSubmission: boolean; + readonly pendingProviderError: unknown; + readonly promptError: unknown; + readonly usage: CapellaUsage; +} + +class CapellaCancellationError extends Error { + override readonly name = 'AbortError'; + + constructor( + readonly usage: CapellaUsage, + cause: Error, + ) { + super('Capella session cancelled.', { cause }); + } +} + +function agentError( + name: CapellaAgentErrorName, + code: CapellaAgentErrorCode, + message: string, + retryable: boolean, + usage?: CapellaUsage, + providerCategory?: ProviderFailureCategory, +): CapellaAgentError { + return new CapellaAgentError(name, code, message, retryable, usage, providerCategory); +} + +function assertRequest(request: CapellaAgentRequest): void { + if (!Number.isInteger(request.maxTurns) || request.maxTurns < 1 || request.maxTurns > MAX_TURNS_PER_SESSION) { + throw agentError('InvalidInputError', 'INVALID_REQUEST', 'Capella maxTurns is outside its bounded range.', false); + } + if (!Number.isInteger(request.timeoutMs) || request.timeoutMs < 1 || request.timeoutMs > MAX_TIMEOUT_MS) { + throw agentError('InvalidInputError', 'INVALID_REQUEST', 'Capella timeoutMs is outside its bounded range.', false); + } + if (!request.cwd || !request.systemPrompt || !request.userPrompt) { + throw agentError('InvalidInputError', 'INVALID_REQUEST', 'Capella request is incomplete.', false); + } + if (request.tools.length > MAX_TOOLS_PER_SESSION) { + throw agentError('InvalidInputError', 'INVALID_TOOL_SET', 'Capella tool count exceeds its bounded limit.', false); + } +} + +// Gate the caller's tool set before a session starts. Repository tools must come from the confined +// factory (never a caller-built look-alike), collectors must be known by name, and nothing outside +// that closed set is allowed. `submit_result` is executor-owned, so a caller supplying one alongside +// an output schema is rejected. Any violation fails the request as invalid input, not a model error. +function validateCallerTools(tools: readonly CapellaTool[], hasOutputSchema: boolean): void { + const names = new Set(); + for (const tool of tools) { + const name = tool.name; + if (!name || names.has(name) || FORBIDDEN_TOOL_NAMES.has(name) || name === 'submit_result') { + throw agentError('InvalidInputError', 'INVALID_TOOL_SET', 'Capella tool set contains a forbidden name.', false); + } + names.add(name); + + if ((CAPELLA_REPOSITORY_TOOL_NAMES as readonly string[]).includes(name)) { + if (!isCapellaRepositoryTool(tool)) { + throw agentError( + 'InvalidInputError', + 'INVALID_TOOL_SET', + 'Capella repository tools must come from the confined tool factory.', + false, + ); + } + continue; + } + if (!CAPELLA_COLLECTOR_TOOL_NAMES.has(name)) { + throw agentError( + 'InvalidInputError', + 'INVALID_TOOL_SET', + 'Capella tool set contains an unknown collector.', + false, + ); + } + } + + if (hasOutputSchema && names.has('submit_result')) { + throw agentError('InvalidInputError', 'INVALID_TOOL_SET', 'Capella submit_result is executor-owned.', false); + } +} + +function createCapturedSubmission(schema: TSchema): CapturedSubmission { + let count = 0; + let invalid = false; + let value: unknown; + return { + tool: defineTool({ + name: 'submit_result', + label: 'Submit result', + description: 'Return the final structured result exactly once.', + promptSnippet: 'submit_result: return the final structured result exactly once', + promptGuidelines: ['Call submit_result exactly once as the final action. Do not print JSON as text.'], + parameters: schema, + async execute(_toolCallId, parameters) { + if (!Value.Check(schema, parameters)) { + invalid = true; + throw agentError( + 'AgentExecutionError', + 'INVALID_RESULT', + 'Capella submit_result arguments failed schema validation.', + true, + ); + } + count += 1; + if (count === 1) value = parameters; + return { + content: [{ type: 'text' as const, text: 'Result submitted.' }], + details: undefined, + terminate: true, + }; + }, + }), + getCount: () => count, + getInvalid: () => invalid, + getValue: () => value, + }; +} + +function finiteNonNegative(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function frozenUsage(session: AgentSession, turns: number): CapellaUsage { + const stats = session.getSessionStats(); + return Object.freeze({ + inputTokens: finiteNonNegative(stats.tokens.input), + outputTokens: finiteNonNegative(stats.tokens.output), + cacheReadTokens: finiteNonNegative(stats.tokens.cacheRead), + cacheWriteTokens: finiteNonNegative(stats.tokens.cacheWrite), + costUsd: finiteNonNegative(stats.cost), + turns: finiteNonNegative(turns), + }); +} + +function isAbortLike(error: unknown): boolean { + return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'); +} + +function isRetryableSetupIo(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === 'EAGAIN' || code === 'EBUSY' || code === 'EIO' || code === 'EMFILE' || code === 'ENFILE'; +} + +function cancellationError(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason; + return new DOMException('Capella session cancelled.', 'AbortError'); +} + +function raceWithAbort(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(cancellationError(signal)); + return new Promise((resolve, reject) => { + const onAbort = (): void => reject(cancellationError(signal)); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); +} + +function classifiedModelFailure( + host: ModelHost, + error: unknown, + code: 'PROVIDER_FAILURE' | 'SESSION_FAILURE', + usage?: CapellaUsage, + contextWindow?: number, +): CapellaAgentError { + const failure = host.classify(error, contextWindow); + if (isAbortLike(error) || isRetryableSetupIo(error)) { + return agentError( + 'AgentExecutionError', + code, + 'The model session ended because of a retryable local or provider failure.', + true, + usage, + ); + } + return agentError(failure.type, code, failure.message, failure.retryable, usage, failure.category); +} + +// Map the raw failure to its true cause, with the termination reason taking priority. When the +// session was cancelled or timed out, the caught error is typically the induced abort; surface the +// cancellation or timeout identity instead of misreporting it as a provider or session failure. +function normalizeRunFailure( + error: unknown, + termination: TerminationReason | undefined, + signal: AbortSignal, + host: ModelHost, +): Error { + if (termination === 'cancellation') { + return error instanceof CapellaCancellationError ? error : cancellationError(signal); + } + if (error instanceof CapellaAgentError) return error; + if (termination === 'timeout') { + return agentError('AgentExecutionError', 'TIMEOUT', 'Capella session timed out.', true); + } + if (termination === 'turn-limit') { + return agentError( + 'AgentExecutionError', + 'TURN_LIMIT', + 'An agentic SAST step ran out of turns before finishing.', + true, + ); + } + return classifiedModelFailure(host, error, 'SESSION_FAILURE'); +} + +class StandaloneCapellaAgentExecutor implements CapellaAgentExecutor { + constructor(private readonly host: ModelHost) {} + + async run(request: CapellaAgentRequest): Promise> { + assertRequest(request as CapellaAgentRequest); + validateCallerTools(request.tools, request.outputSchema !== undefined); + + const controller = new AbortController(); + let termination: TerminationReason | undefined; + let session: AgentSession | undefined; + let unsubscribe: (() => void) | undefined; + let timeout: NodeJS.Timeout | undefined; + let turnCount = 0; + let operationCount = 0; + const terminate = (reason: TerminationReason): void => { + if (termination !== undefined) return; + termination = reason; + controller.abort(new DOMException(`Capella session ${reason}.`, 'AbortError')); + void session?.abort().catch(() => undefined); + }; + const onCancellation = (): void => terminate('cancellation'); + + if (request.signal.aborted) throw cancellationError(request.signal); + request.signal.addEventListener('abort', onCancellation, { once: true }); + timeout = setTimeout(() => terminate('timeout'), request.timeoutMs); + + try { + let selection: ModelSelection; + try { + selection = await raceWithAbort(this.host.resolve(request.role), controller.signal); + } catch (error) { + if (termination === 'cancellation') throw cancellationError(request.signal); + if (termination === 'timeout') { + throw agentError('AgentExecutionError', 'TIMEOUT', 'Capella session timed out.', true); + } + throw classifiedModelFailure(this.host, error, 'PROVIDER_FAILURE'); + } + + const submit = request.outputSchema ? createCapturedSubmission(request.outputSchema) : undefined; + const customTools = [...request.tools, ...(submit ? [submit.tool] : [])]; + const toolNames = customTools.map((tool) => tool.name); + const systemPrompt = submit + ? `${request.systemPrompt}\n\nYou MUST call submit_result exactly once as your final action. Do not output JSON as text.` + : request.systemPrompt; + const agentDir = getAgentDir(); + const settingsManager = SettingsManager.inMemory({ + retry: PI_RETRY_SETTINGS, + compaction: { enabled: true }, + }); + + const resourceLoader = new DefaultResourceLoader({ + cwd: request.cwd, + agentDir, + settingsManager, + systemPrompt, + appendSystemPrompt: [], + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + }); + await raceWithAbort(resourceLoader.reload(), controller.signal); + + const sessionPromise = createAgentSession({ + cwd: request.cwd, + agentDir, + model: selection.model, + modelRuntime: selection.modelRuntime, + noTools: 'all', + tools: toolNames, + customTools, + resourceLoader, + sessionManager: SessionManager.inMemory(), + settingsManager, + }); + try { + ({ session } = await raceWithAbort(sessionPromise, controller.signal)); + } catch (error) { + void sessionPromise.then( + async ({ session: lateSession }) => { + await lateSession.abort().catch(() => undefined); + try { + lateSession.dispose(); + } catch { + // The late session is already aborted; cleanup remains best effort. + } + }, + () => undefined, + ); + throw error; + } + + if (controller.signal.aborted) { + await session.abort().catch(() => undefined); + } else { + controller.signal.addEventListener('abort', () => void session?.abort().catch(() => undefined), { + once: true, + }); + } + + // Re-check the live session's tools against the intended set. If pi registered anything extra + // or dropped one, tool isolation broke, so fail closed before the model runs. + const configuredToolNames = session + .getAllTools() + .map((tool) => tool.name) + .sort(); + if (configuredToolNames.join('\0') !== [...toolNames].sort().join('\0')) { + throw agentError( + 'ConfigurationError', + 'INVALID_TOOL_SET', + 'An agentic SAST step could not start with the tools it needs.', + false, + ); + } + + let invalidSubmission = false; + let pendingProviderError: unknown; + unsubscribe = session.subscribe((event: AgentSessionEvent) => { + if (event.type === 'tool_execution_start') { + operationCount += 1; + return; + } + if (event.type === 'tool_execution_end') { + if (event.toolName === 'submit_result' && event.isError) invalidSubmission = true; + return; + } + if (event.type !== 'turn_end') return; + + turnCount += 1; + const message: AgentMessage = event.message; + if (message.role === 'assistant' && message.stopReason === 'error') { + pendingProviderError ??= message; + } + const needsAnotherTurn = message.role === 'assistant' && message.stopReason === 'toolUse'; + if (turnCount >= request.maxTurns && needsAnotherTurn && (submit?.getCount() ?? 0) === 0) { + terminate('turn-limit'); + } + }); + + let promptError: unknown; + try { + await raceWithAbort(session.prompt(request.userPrompt, { expandPromptTemplates: false }), controller.signal); + } catch (error) { + promptError = error; + } + + const outcome: SessionOutcome = { + submissionCount: submit?.getCount() ?? 0, + submissionValue: submit?.getValue(), + invalidSubmission: invalidSubmission || (submit?.getInvalid() ?? false), + pendingProviderError, + promptError, + usage: frozenUsage(session, turnCount), + }; + const output = this.resolveOutcome(request, outcome, termination, selection.model.contextWindow); + + return { output, usage: outcome.usage }; + } catch (error) { + const surfacedError = normalizeRunFailure(error, termination, request.signal, this.host); + throw surfacedError; + } finally { + if (timeout) clearTimeout(timeout); + request.signal.removeEventListener('abort', onCancellation); + try { + unsubscribe?.(); + } catch { + // Subscription cleanup is best effort after the session has ended. + } + try { + session?.dispose(); + } catch { + // Session cleanup is best effort after abort or completion. + } + } + } + + private resolveOutcome( + request: CapellaAgentRequest, + outcome: SessionOutcome, + termination: TerminationReason | undefined, + contextWindow?: number, + ): T { + if (termination === 'cancellation') { + throw new CapellaCancellationError(outcome.usage, cancellationError(request.signal)); + } + if (termination === 'timeout') { + throw agentError('AgentExecutionError', 'TIMEOUT', 'Capella session timed out.', true, outcome.usage); + } + if (termination === 'turn-limit') { + throw agentError( + 'AgentExecutionError', + 'TURN_LIMIT', + 'An agentic SAST step ran out of turns before finishing.', + true, + outcome.usage, + ); + } + if (outcome.invalidSubmission && outcome.submissionCount === 0) { + throw agentError( + 'AgentExecutionError', + 'INVALID_RESULT', + 'Capella submit_result arguments failed schema validation.', + true, + outcome.usage, + ); + } + if (outcome.submissionCount > 1) { + throw agentError( + 'AgentExecutionError', + 'DUPLICATE_RESULT', + 'An agentic SAST step returned its result twice.', + true, + outcome.usage, + ); + } + if (outcome.pendingProviderError !== undefined) { + const failure = this.host.classify(outcome.pendingProviderError, contextWindow); + throw agentError( + failure.type, + 'PROVIDER_FAILURE', + failure.message, + failure.retryable, + outcome.usage, + failure.category, + ); + } + // An abort after exactly one accepted submission is the normal end of a good run: the submit tool + // terminates the session. Treat it as success; any other prompt error is a real session failure. + if (outcome.promptError !== undefined && !(outcome.submissionCount === 1 && isAbortLike(outcome.promptError))) { + throw classifiedModelFailure(this.host, outcome.promptError, 'SESSION_FAILURE', outcome.usage, contextWindow); + } + if (request.outputSchema !== undefined) { + if (outcome.submissionCount !== 1 || outcome.submissionValue === undefined) { + throw agentError( + 'AgentExecutionError', + 'MISSING_RESULT', + 'Capella session ended without one structured result.', + true, + outcome.usage, + ); + } + return outcome.submissionValue as T; + } + return undefined as T; + } +} + +/** Create a Capella executor over the process-local credential-preserving model host. */ +export function createCapellaAgentExecutor(host: ModelHost = modelHost): CapellaAgentExecutor { + return new StandaloneCapellaAgentExecutor(host); +} + +/** Process-local standalone Capella executor. */ +export const capellaAgentExecutor: CapellaAgentExecutor = createCapellaAgentExecutor(); diff --git a/apps/worker/src/ai/pi/capella-agent-types.ts b/apps/worker/src/ai/pi/capella-agent-types.ts new file mode 100644 index 00000000..1e99d3c6 --- /dev/null +++ b/apps/worker/src/ai/pi/capella-agent-types.ts @@ -0,0 +1,38 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import type { ToolDefinition } from '@earendil-works/pi-coding-agent'; +import type { TSchema } from 'typebox'; +import type { ModelRole } from '../model-host.js'; +import type { CapellaStage, CapellaUsage } from '../sast/types.js'; + +/** A Capella-owned collector or repository tool installed in one confined session. */ +export type CapellaTool = ToolDefinition; + +/** One bounded multi-turn Capella model session. */ +export interface CapellaAgentRequest<_T> { + readonly stage: CapellaStage; + readonly role: ModelRole; + readonly cwd: string; + readonly systemPrompt: string; + readonly userPrompt: string; + readonly maxTurns: number; + readonly timeoutMs: number; + readonly tools: readonly CapellaTool[]; + readonly outputSchema?: TSchema; + readonly signal: AbortSignal; +} + +/** Schema-valid output and measured usage from one completed Capella session. */ +export interface CapellaAgentResponse { + readonly output: T; + readonly usage: CapellaUsage; +} + +/** Standalone executor boundary consumed by the Capella stage implementation. */ +export interface CapellaAgentExecutor { + run(request: CapellaAgentRequest): Promise>; +} diff --git a/apps/worker/src/ai/pi/retry-settings.ts b/apps/worker/src/ai/pi/retry-settings.ts index 48d28e4c..77ceb89e 100644 --- a/apps/worker/src/ai/pi/retry-settings.ts +++ b/apps/worker/src/ai/pi/retry-settings.ts @@ -18,11 +18,11 @@ * * NOTE: pi recommends keeping this at 0, since SDK-level retries consume * out-of-usage-limit responses before pi's classifier can mark them terminal. - * Shannon accepts that trade for the transport-fault coverage. `maxRetryDelayMs` - * is left at pi's 60s default so a server asking for a longer wait fails fast - * instead of parking the activity. + * Shannon accepts that trade for the transport-fault coverage. A two-minute + * delay cap lets short server-directed recovery remain in the current session; + * longer delays return to Temporal's bounded activity retry policy. */ export const PI_RETRY_SETTINGS = { enabled: false, - provider: { maxRetries: 8 }, + provider: { maxRetries: 8, maxRetryDelayMs: 120_000 }, } as const; diff --git a/apps/worker/src/ai/pi/structured-generation.ts b/apps/worker/src/ai/pi/structured-generation.ts new file mode 100644 index 00000000..f5eb7496 --- /dev/null +++ b/apps/worker/src/ai/pi/structured-generation.ts @@ -0,0 +1,118 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import type { AssistantMessage, Context, ToolCall } from '@earendil-works/pi-ai'; +import { Value } from 'typebox/value'; +import { type ModelHost, modelHost } from '../model-host.js'; +import type { + StructuredGenerationPort, + StructuredGenerationRequest, + StructuredGenerationResult, +} from '../structured-generation.js'; +import { type CapturedSubmitTool, createGenericSubmitTool } from '../submit-tool.js'; + +const ZERO_USAGE = { inputTokens: 0, outputTokens: 0, costUsd: 0 } as const; + +function isAbort(error: unknown, signal: AbortSignal | undefined): boolean { + const abortError = error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'); + return signal?.aborted === true || abortError; +} + +function responseUsage(response: AssistantMessage): StructuredGenerationResult['usage'] { + return { + inputTokens: response.usage.input, + outputTokens: response.usage.output, + costUsd: response.usage.cost.total, + }; +} + +type SubmitExecutor = (toolCallId: string, parameters: Record) => Promise; + +async function captureSingleValidSubmission( + toolCalls: readonly ToolCall[], + submitTool: CapturedSubmitTool, +): Promise> { + const returnedCalls = toolCalls.map((call) => ({ name: call.name, arguments: call.arguments })); + const call = toolCalls.length === 1 ? toolCalls[0] : undefined; + if (call?.name !== submitTool.tool.name) return returnedCalls; + + if (!Value.Check(submitTool.tool.parameters, call.arguments)) return returnedCalls; + + // completeSimple returns tool calls but does not execute them. Invoke the captured + // definition only after its TypeBox validator accepts the sole submission. + const execute = submitTool.tool.execute as unknown as SubmitExecutor; + await execute(call.id, call.arguments); + const captured = submitTool.getCaptured(); + return [{ name: call.name, arguments: captured }]; +} + +async function generate(host: ModelHost, request: StructuredGenerationRequest): Promise { + const selection = await host.resolve('small'); + const submitTool = createGenericSubmitTool(request.tool.parametersJsonSchema); + const context: Context = { + ...(request.systemPrompt !== undefined && { systemPrompt: request.systemPrompt }), + messages: [{ role: 'user', content: request.userContent, timestamp: Date.now() }], + tools: [ + { + name: submitTool.tool.name, + description: request.tool.description, + parameters: submitTool.tool.parameters, + }, + ], + }; + + let response: AssistantMessage; + try { + // One enrichment batch is one billable provider request. Temporal owns any + // retry after this boundary, so provider-level retries stay disabled here. + response = await selection.modelRuntime.completeSimple(selection.model, context, { + maxTokens: request.maxTokens, + maxRetries: 0, + ...(request.signal !== undefined && { signal: request.signal }), + }); + } catch (error) { + if (isAbort(error, request.signal)) { + return { stopReason: 'aborted', toolCalls: [], usage: ZERO_USAGE }; + } + const failure = host.classify(error); + return { + stopReason: 'error', + toolCalls: [], + usage: ZERO_USAGE, + errorMessage: `${failure.type}: ${failure.message}`, + }; + } + + if (response.stopReason === 'error') { + const failure = host.classify(response); + return { + stopReason: 'error', + toolCalls: [], + usage: responseUsage(response), + errorMessage: `${failure.type}: ${failure.message}`, + }; + } + if (response.stopReason === 'aborted') { + return { stopReason: 'aborted', toolCalls: [], usage: responseUsage(response) }; + } + + const toolCalls = response.content.filter((block): block is ToolCall => block.type === 'toolCall'); + const capturedCalls = await captureSingleValidSubmission(toolCalls, submitTool); + return { + stopReason: response.stopReason, + toolCalls: capturedCalls, + usage: responseUsage(response), + }; +} + +/** Build the one-request Pi adapter used by SAST enrichment. */ +export function createPiStructuredGenerationPort(host: ModelHost = modelHost): StructuredGenerationPort { + return { + generate(request: StructuredGenerationRequest): Promise { + return generate(host, request); + }, + }; +} diff --git a/apps/worker/src/ai/pi/turn-error.ts b/apps/worker/src/ai/pi/turn-error.ts index c5caadba..f5079034 100644 --- a/apps/worker/src/ai/pi/turn-error.ts +++ b/apps/worker/src/ai/pi/turn-error.ts @@ -4,41 +4,32 @@ // it under the terms of the GNU Affero General Public License version 3 // as published by the Free Software Foundation. -import { type AssistantMessage, isContextOverflow, isRetryableAssistantError } from '@earendil-works/pi-ai'; -import { PentestError } from '../../services/error-handling.js'; +import type { AssistantMessage } from '@earendil-works/pi-ai'; +import { classifyProviderFailure, PentestError } from '../../services/error-handling.js'; import { ErrorCode } from '../../types/errors.js'; /** - * Wrap a failed assistant turn, taking the verdict from pi. + * Wrap a failed assistant turn, taking the retry verdict from pi. * - * Overflow is separated first, as pi's retry contract requires: it means the - * request was too large, not that the provider faltered, so an identical retry - * would overflow again. Everything else goes to pi's classifier, which treats - * quota, billing, and auth exhaustion as terminal and load, throttling, and - * transport faults as transient — those were already retried in-session, so - * reaching here means the attempts were exhausted. + * There is one decision point: the shared classifier. It defers retryability to pi's own + * helper (load, throttling, and transport faults are transient; quota, billing, and context + * overflow are terminal — the transient ones were already retried in-session, so reaching here + * means the attempts were exhausted) and derives a separate observational category. * - * `contextWindow` is omitted where overflow cannot apply, such as a one-word - * credential probe. + * A raw provider message never carries an auth/config ErrorCode — only the observational + * category may say so — so it stays AGENT_EXECUTION_FAILED and cannot trip Temporal's + * non-retryable type gate on a guess. `contextWindow` lets the classifier detect overflow; + * it is omitted where overflow cannot apply, such as a one-word credential probe. */ export function providerTurnError(message: AssistantMessage, label: string, contextWindow?: number): PentestError { - const detail = (message.errorMessage ?? 'unknown provider error').slice(0, 300); - - if (contextWindow !== undefined && isContextOverflow(message, contextWindow)) { - return new PentestError( - `${label}: context window exceeded after compaction: ${detail}`, - 'unknown', - false, - { contextWindow }, - ErrorCode.AGENT_EXECUTION_FAILED, - ); - } - - return new PentestError( - `${label}: ${detail}`, + const failure = classifyProviderFailure(message, contextWindow); + const error = new PentestError( + `${label}: ${failure.message}`, 'unknown', - isRetryableAssistantError(message), + failure.retryable, {}, ErrorCode.AGENT_EXECUTION_FAILED, ); + error.providerCategory = failure.category; + return error; } diff --git a/apps/worker/src/ai/sast/capella/artifacts.ts b/apps/worker/src/ai/sast/capella/artifacts.ts new file mode 100644 index 00000000..2e3cc8df --- /dev/null +++ b/apps/worker/src/ai/sast/capella/artifacts.ts @@ -0,0 +1,568 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { execFile } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, realpath, rename, rm } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { isProviderFailureCategory } from '../../../types/errors.js'; +import type { AgenticSastReduction, CapellaStage, CapellaUsage, SarifRef } from '../types.js'; +import { InvalidInputError, SastContractError } from './errors.js'; +import { + type AtomicPublishOptions, + type CapellaArtifactEnvelope, + type CapellaArtifactRef, + type CapellaRunFailure, + type CapellaRunRecord, + type CapellaStageInput, + type StageArtifactValidator, + type StageUsageSummary, + usageAccountingWarning, + ZERO_CAPELLA_USAGE, +} from './types.js'; +import { isAgenticSastReduction } from './validation.js'; + +const execFileAsync = promisify(execFile); +const STAGE_ORDER: readonly CapellaStage[] = [ + 'architecture', + 'threat-model', + 'plan', + 'research', + 'dedupe', + 'review', + 'critic', + 'confirm', + 'calibrate', + 'export', +]; +const FAILURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; + +// A recorded failure code is either an internal SCREAMING_SNAKE_CASE code this module minted +// (ARTIFACT_PATH, SARIF_DIGEST, ...) or a provider failure category forwarded verbatim from the +// model harness; both are bounded, closed vocabularies safe to persist in run.json. +function isFailureCode(value: unknown): value is string { + return typeof value === 'string' && (FAILURE_CODE_PATTERN.test(value) || isProviderFailureCategory(value)); +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + const output: Record = Object.create(null) as Record; + for (const key of Object.keys(value).sort()) { + const child = (value as Record)[key]; + if (child !== undefined) output[key] = canonicalize(child); + } + return output; + } + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new SastContractError('Capella artifacts cannot contain non-finite numbers', 'ARTIFACT_NON_FINITE'); + } + return value; +} + +/** Serialize a JSON value with recursively sorted object keys. */ +export function stableJson(value: unknown): string { + return `${JSON.stringify(canonicalize(value), null, 2)}\n`; +} + +/** Lowercase SHA-256 over exact bytes. */ +export function sha256Bytes(bytes: string | Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** Deterministic fingerprint over a closed set of named inputs. */ +export function buildFingerprint(parts: Record): string { + return sha256Bytes(stableJson(parts)); +} + +/** Resolve the immutable repository commit used by all stage fingerprints. */ +export async function repositoryIdentity(repoPath: string): Promise { + let realRepoPath: string; + try { + realRepoPath = await realpath(repoPath); + } catch { + throw new InvalidInputError('Capella repository root does not exist', 'REPOSITORY_UNAVAILABLE'); + } + + try { + const { stdout } = await execFileAsync('git', ['-C', realRepoPath, 'rev-parse', '--verify', 'HEAD'], { + encoding: 'utf8', + maxBuffer: 64 * 1024, + }); + const commit = stdout.trim().toLowerCase(); + if (!/^[0-9a-f]{40,64}$/.test(commit)) throw new Error('invalid commit'); + return commit; + } catch { + throw new InvalidInputError('Capella requires a repository with a valid HEAD commit', 'REPOSITORY_HEAD'); + } +} + +/** + * The run-level identity every stage fingerprint is built on top of. Changing any field here + * (a different repository commit, model, format or prompt-set version, or code-path scope) + * must invalidate every artifact from a prior run rather than let a resumed scan silently mix + * outputs produced under different assumptions. + */ +export function buildRunInputFingerprint(input: CapellaStageInput, repoIdentity: string): string { + return buildFingerprint({ + repositoryIdentity: repoIdentity, + modelSpec: input.modelSpec, + capellaFormatVersion: input.capellaFormatVersion, + promptSetVersion: input.promptSetVersion, + codePathAvoids: [...input.codePathAvoids].sort(), + codePathFocus: [...input.codePathFocus].sort(), + pipelineTestingMode: input.pipelineTestingMode, + }); +} + +export function stageArtifactPath(artifactRoot: string, stage: CapellaStage): string { + return resolve(artifactRoot, 'stages', `${stage}.json`); +} + +/** Reject any publish target outside the artifact root, including the root itself. */ +function assertOwnedPath(artifactRoot: string, targetPath: string): void { + const root = resolve(artifactRoot); + const target = resolve(targetPath); + const rel = relative(root, target); + if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) { + throw new InvalidInputError('Capella artifact path escapes its artifact root', 'ARTIFACT_PATH'); + } +} + +/** + * Publish exact bytes through a unique sibling and one atomic rename. + * + * The handle is fsynced before the rename so the visible path can never hold + * partial bytes after a crash; on any failure the temporary sibling is removed + * and the final path is untouched. + */ +export async function atomicPublishBytes( + artifactRoot: string, + finalPath: string, + bytes: string | Uint8Array, + options: AtomicPublishOptions = {}, +): Promise { + assertOwnedPath(artifactRoot, finalPath); + await mkdir(dirname(finalPath), { recursive: true }); + const temporaryPath = resolve(dirname(finalPath), `.${basename(finalPath)}.${process.pid}.${randomUUID()}.tmp`); + let handle: Awaited> | undefined; + try { + handle = await open(temporaryPath, 'wx', 0o600); + await handle.writeFile(bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + await options.beforeRename?.(temporaryPath, finalPath); + await rename(temporaryPath, finalPath); + return sha256Bytes(bytes); + } catch (error) { + await handle?.close().catch(() => undefined); + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw error; + } +} + +export async function atomicPublishJson( + artifactRoot: string, + finalPath: string, + value: unknown, + options: AtomicPublishOptions = {}, +): Promise<{ readonly sha256: string; readonly bytes: string }> { + const bytes = stableJson(value); + const sha256 = await atomicPublishBytes(artifactRoot, finalPath, bytes, options); + return { sha256, bytes }; +} + +function isUsage(value: unknown): value is CapellaUsage { + if (!value || typeof value !== 'object') return false; + const usage = value as Record; + const counters = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'turns']; + return ( + counters.every((key) => Number.isSafeInteger(usage[key]) && Number(usage[key]) >= 0) && + typeof usage.costUsd === 'number' && + Number.isFinite(usage.costUsd) && + usage.costUsd >= 0 + ); +} + +function isEnvelope( + value: unknown, + stage: CapellaStage, + fingerprint: string, + validate: StageArtifactValidator, +): value is CapellaArtifactEnvelope { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return ( + record.schemaVersion === 1 && + record.stage === stage && + record.fingerprint === fingerprint && + isUsage(record.usage) && + validate(record.value) + ); +} + +export interface LoadedArtifact { + readonly ref: CapellaArtifactRef; + readonly value: T; + readonly usage: CapellaUsage; +} + +/** Return only a schema-valid, fingerprint-matching completed artifact. */ +export async function loadCompletedArtifact( + artifactRoot: string, + finalPath: string, + stage: CapellaStage, + fingerprint: string, + validate: StageArtifactValidator, +): Promise | undefined> { + assertOwnedPath(artifactRoot, finalPath); + try { + const bytes = await readFile(finalPath); + const parsed: unknown = JSON.parse(bytes.toString('utf8')); + if (!isEnvelope(parsed, stage, fingerprint, validate)) return undefined; + return { + ref: { path: finalPath, sha256: sha256Bytes(bytes), fingerprint }, + value: parsed.value, + usage: parsed.usage, + }; + } catch { + return undefined; + } +} + +/** Load and verify a stage artifact supplied by an earlier activity. */ +export async function loadArtifactRef( + artifactRoot: string, + ref: CapellaArtifactRef, + stage: CapellaStage, + validate: StageArtifactValidator, +): Promise> { + assertOwnedPath(artifactRoot, ref.path); + if (resolve(ref.path) !== stageArtifactPath(artifactRoot, stage)) { + throw new SastContractError(`${stage} artifact has an unexpected path`, 'ARTIFACT_PATH'); + } + let bytes: Buffer; + try { + bytes = await readFile(ref.path); + } catch { + throw new SastContractError(`${stage} artifact is missing`, 'ARTIFACT_MISSING'); + } + if (sha256Bytes(bytes) !== ref.sha256) { + throw new SastContractError(`${stage} artifact digest mismatch`, 'ARTIFACT_DIGEST'); + } + let parsed: unknown; + try { + parsed = JSON.parse(bytes.toString('utf8')); + } catch { + throw new SastContractError(`${stage} artifact is not valid JSON`, 'ARTIFACT_JSON'); + } + if (!isEnvelope(parsed, stage, ref.fingerprint, validate)) { + throw new SastContractError(`${stage} artifact failed schema or fingerprint validation`, 'ARTIFACT_SCHEMA'); + } + return { ref, value: parsed.value, usage: parsed.usage }; +} + +export async function publishStageArtifact( + artifactRoot: string, + stage: CapellaStage, + fingerprint: string, + usage: CapellaUsage, + value: T, +): Promise { + const finalPath = stageArtifactPath(artifactRoot, stage); + const envelope: CapellaArtifactEnvelope = { schemaVersion: 1, stage, fingerprint, usage, value }; + const { sha256 } = await atomicPublishJson(artifactRoot, finalPath, envelope); + return { path: finalPath, sha256, fingerprint }; +} + +/** Publish a fingerprinted checkpoint whose path is stage-owned but not the stage completion marker. */ +export async function publishCheckpointArtifact( + artifactRoot: string, + finalPath: string, + stage: CapellaStage, + fingerprint: string, + usage: CapellaUsage, + value: T, +): Promise { + const envelope: CapellaArtifactEnvelope = { schemaVersion: 1, stage, fingerprint, usage, value }; + const { sha256 } = await atomicPublishJson(artifactRoot, finalPath, envelope); + return { path: finalPath, sha256, fingerprint }; +} + +export function addUsage(left: CapellaUsage, right: CapellaUsage): CapellaUsage { + return { + inputTokens: left.inputTokens + right.inputTokens, + outputTokens: left.outputTokens + right.outputTokens, + cacheReadTokens: left.cacheReadTokens + right.cacheReadTokens, + cacheWriteTokens: left.cacheWriteTokens + right.cacheWriteTokens, + costUsd: left.costUsd + right.costUsd, + turns: left.turns + right.turns, + }; +} + +function sumStageUsage(stageUsage: Partial>): CapellaUsage { + return STAGE_ORDER.reduce( + (total, stage) => addUsage(total, stageUsage[stage] ?? ZERO_CAPELLA_USAGE), + ZERO_CAPELLA_USAGE, + ); +} + +/** A run's reduced-coverage set: valid members, at most one per stage, in stage order. */ +function isReductionSet(value: unknown): value is readonly AgenticSastReduction[] { + if (!Array.isArray(value) || !value.every(isAgenticSastReduction)) return false; + const stages = value.map((reduction) => reduction.stage); + if (new Set(stages).size !== stages.length) return false; + const positions = stages.map((stage) => STAGE_ORDER.indexOf(stage)); + return positions.every((position, index) => index === 0 || position > (positions[index - 1] ?? -1)); +} + +/** Fold one reduction into a run's set, replacing any prior entry for the same stage, in stage order. */ +function mergeReductions( + existing: readonly AgenticSastReduction[], + reduction: AgenticSastReduction, +): AgenticSastReduction[] { + const byStage = new Map(); + for (const entry of existing) byStage.set(entry.stage, entry); + byStage.set(reduction.stage, reduction); + return STAGE_ORDER.filter((stage) => byStage.has(stage)).map((stage) => byStage.get(stage) as AgenticSastReduction); +} + +// completedStages must read as a prefix of STAGE_ORDER with no gaps skipped backward, so a +// corrupted or hand-edited run.json cannot claim a later stage completed without its predecessors. +function stagesAreStrictlyOrdered(stages: readonly CapellaStage[]): boolean { + for (let index = 1; index < stages.length; index += 1) { + const previous = stages[index - 1]; + const current = stages[index]; + if (!previous || !current || STAGE_ORDER.indexOf(current) <= STAGE_ORDER.indexOf(previous)) return false; + } + return true; +} + +// The 2,000-character error bound and the attempt/retryable shape keep a persisted failure record +// wire-sized and closed, so a provider or filesystem error cannot inflate run.json with unbounded text. +function isRunFailure(value: unknown): value is CapellaRunFailure { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const failure = value as Record; + return ( + (failure.stage === 'workflow' || STAGE_ORDER.includes(failure.stage as CapellaStage)) && + isFailureCode(failure.code) && + typeof failure.error === 'string' && + failure.error.length > 0 && + failure.error.length <= 2_000 && + Number.isSafeInteger(failure.attempt) && + Number(failure.attempt) >= 1 && + typeof failure.retryable === 'boolean' + ); +} + +function isRunRecord(value: unknown): value is CapellaRunRecord { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + if (record.schemaVersion !== 1) return false; + if (typeof record.capellaFormatVersion !== 'string' || typeof record.promptSetVersion !== 'string') return false; + if (typeof record.inputFingerprint !== 'string' || !/^[0-9a-f]{64}$/.test(record.inputFingerprint)) return false; + if (!Array.isArray(record.completedStages)) return false; + if (!record.completedStages.every((stage) => STAGE_ORDER.includes(stage as CapellaStage))) return false; + if (new Set(record.completedStages).size !== record.completedStages.length) return false; + if (!Array.isArray(record.warnings)) return false; + if (!record.warnings.every((warning) => typeof warning === 'string' && warning.length <= 2_000)) return false; + if (!isUsage(record.usage)) return false; + if (typeof record.usageAccountingComplete !== 'boolean') return false; + if (!record.stageUsage || typeof record.stageUsage !== 'object' || Array.isArray(record.stageUsage)) return false; + + const completedStages = record.completedStages as CapellaStage[]; + if (!stagesAreStrictlyOrdered(completedStages)) return false; + const stageUsage = record.stageUsage as Record; + if ( + Object.keys(stageUsage).some((stage) => !STAGE_ORDER.includes(stage as CapellaStage) || !isUsage(stageUsage[stage])) + ) { + return false; + } + + if (record.reductions !== undefined && !isReductionSet(record.reductions)) return false; + + if (record.finalState === 'succeeded') { + if ( + !completedStages.includes('export') || + !record.sarif || + typeof record.sarif !== 'object' || + record.failure !== undefined + ) { + return false; + } + const sarif = record.sarif as Record; + return typeof sarif.path === 'string' && typeof sarif.sha256 === 'string' && /^[0-9a-f]{64}$/.test(sarif.sha256); + } + if (record.finalState === 'failed') { + return isRunFailure(record.failure) && record.sarif === undefined; + } + // A running record may carry a failure only while it is retryable: that is + // an attempt in flight, not a terminal outcome. + return ( + record.finalState === 'running' && + record.sarif === undefined && + (record.failure === undefined || (isRunFailure(record.failure) && record.failure.retryable)) + ); +} + +/** + * Load only the current input's schema-valid Capella run record. + * + * Any mismatch (schema, fingerprint, version, or a succeeded record whose SARIF + * is not the canonical `capella.sarif` path) reads as absent, so a resumed run + * starts fresh instead of adopting progress it cannot trust. + */ +export async function loadRunRecord( + input: CapellaStageInput, + inputFingerprint: string, +): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(resolve(input.artifactRoot, 'run.json'), 'utf8')); + if (!isRunRecord(parsed)) return undefined; + if (parsed.inputFingerprint !== inputFingerprint) return undefined; + if (parsed.capellaFormatVersion !== input.capellaFormatVersion) return undefined; + if (parsed.promptSetVersion !== input.promptSetVersion) return undefined; + if (parsed.finalState === 'succeeded' && parsed.sarif?.path !== resolve(input.artifactRoot, 'capella.sarif')) { + return undefined; + } + return parsed; + } catch { + return undefined; + } +} + +export async function recordStageCompletion( + input: CapellaStageInput, + inputFingerprint: string, + stage: CapellaStage, + usage: CapellaUsage, + warnings: readonly string[] = [], + sarif?: SarifRef, + reductions: readonly AgenticSastReduction[] = [], +): Promise { + const existing = await loadRunRecord(input, inputFingerprint); + const stageUsage = { ...(existing?.stageUsage ?? {}), [stage]: usage }; + // Rebuilt from STAGE_ORDER so the list stays canonically ordered and + // deduplicated no matter which stage reports first after a resume. + const completedStages = STAGE_ORDER.filter( + (candidate) => candidate === stage || existing?.completedStages.includes(candidate), + ); + const mergedWarnings = [...new Set([...(existing?.warnings ?? []), ...warnings])].sort(); + const mergedReductions = reductions.reduce( + (current, reduction) => mergeReductions(current, reduction), + [...(existing?.reductions ?? [])], + ); + const record: CapellaRunRecord = { + schemaVersion: 1, + capellaFormatVersion: input.capellaFormatVersion, + promptSetVersion: input.promptSetVersion, + inputFingerprint, + completedStages, + finalState: sarif ? 'succeeded' : 'running', + warnings: mergedWarnings, + usage: sumStageUsage(stageUsage), + stageUsage, + // Optimistic: this write carries only the successful attempt's spend. recordStageUsageAccounting + // reconciles the figure against the full attempt ledger and downgrades this if the stage retried. + usageAccountingComplete: existing?.usageAccountingComplete ?? true, + ...(mergedReductions.length > 0 ? { reductions: mergedReductions } : {}), + ...(sarif ? { sarif } : {}), + }; + await atomicPublishJson(input.artifactRoot, resolve(input.artifactRoot, 'run.json'), record); +} + +/** + * Reconcile a completed stage's spend against its full per-attempt usage ledger. + * + * recordStageCompletion writes the successful attempt's usage as a crash-safe marker; this + * heals that figure to the ledger aggregate (which includes failed attempts) once the activity + * has folded the ledger. A retried or ledger-incomplete stage drives usageAccountingComplete + * false and names the reason in warnings. Absent record: the completion write must run first, + * so there is nothing to reconcile. + */ +export async function recordStageUsageAccounting( + input: CapellaStageInput, + inputFingerprint: string, + stage: CapellaStage, + summary: StageUsageSummary, +): Promise { + const existing = await loadRunRecord(input, inputFingerprint); + if (!existing) return; + const stageUsage = { ...existing.stageUsage, [stage]: summary.usage }; + const stageComplete = summary.complete && !summary.retried; + const warnings = stageComplete + ? existing.warnings + : [...new Set([...existing.warnings, usageAccountingWarning(stage)])].sort(); + const record: CapellaRunRecord = { + ...existing, + warnings, + usage: sumStageUsage(stageUsage), + stageUsage, + usageAccountingComplete: existing.usageAccountingComplete && stageComplete, + }; + await atomicPublishJson(input.artifactRoot, resolve(input.artifactRoot, 'run.json'), record); +} + +export interface RecordRunFailureOptions { + /** Keep an original fallback-stage failure when its replacement export did not complete. */ + readonly preserveExistingFailure?: boolean; + /** Keep a success that this activity invocation itself completed before later bookkeeping failed. */ + readonly preserveExistingSuccess?: boolean; +} + +export async function recordRunFailure( + input: CapellaStageInput, + inputFingerprint: string, + failure: CapellaRunFailure, + terminal: boolean, + stageUsageSummary?: StageUsageSummary, + options: RecordRunFailureOptions = {}, +): Promise { + const existing = await loadRunRecord(input, inputFingerprint); + if (options.preserveExistingSuccess && existing?.finalState === 'succeeded') return; + if (options.preserveExistingFailure && existing?.finalState === 'failed') return; + const failureIsFinal = terminal || !failure.retryable; + // A failing stage still spent tokens; fold its ledger aggregate in so the durable record + // counts it. Verify accounting against the same ledger predicate every other ledger uses: + // a stage whose spend reconciles (complete and un-retried) keeps the run trusted and clears + // its warning; anything unverifiable stays incomplete and names the reason. + const stageUsage = + stageUsageSummary && failure.stage !== 'workflow' + ? { ...(existing?.stageUsage ?? {}), [failure.stage]: stageUsageSummary.usage } + : (existing?.stageUsage ?? {}); + const stageComplete = + stageUsageSummary !== undefined && + failure.stage !== 'workflow' && + stageUsageSummary.complete && + !stageUsageSummary.retried; + const usageAccountingComplete = (existing?.usageAccountingComplete ?? true) && stageComplete; + const warnings = stageComplete + ? (existing?.warnings ?? []) + : [...new Set([...(existing?.warnings ?? []), usageAccountingWarning(failure.stage)])].sort(); + const record: CapellaRunRecord = { + schemaVersion: 1, + capellaFormatVersion: input.capellaFormatVersion, + promptSetVersion: input.promptSetVersion, + inputFingerprint, + completedStages: existing?.completedStages ?? [], + finalState: failureIsFinal ? 'failed' : 'running', + warnings, + usage: sumStageUsage(stageUsage), + stageUsage, + usageAccountingComplete, + ...(existing?.reductions ? { reductions: existing.reductions } : {}), + failure: { + stage: failure.stage, + code: isFailureCode(failure.code) ? failure.code : 'ACTIVITY_FAILURE', + error: failure.error.slice(0, 2_000) || 'Capella run failed', + attempt: failure.attempt, + retryable: failure.retryable, + }, + }; + await atomicPublishJson(input.artifactRoot, resolve(input.artifactRoot, 'run.json'), record); +} diff --git a/apps/worker/src/ai/sast/capella/collectors.ts b/apps/worker/src/ai/sast/capella/collectors.ts new file mode 100644 index 00000000..809e38cd --- /dev/null +++ b/apps/worker/src/ai/sast/capella/collectors.ts @@ -0,0 +1,1084 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * Capella's structured collectors. + * + * Capella never parses markdown on the ingestion path. Each verdict stage + * registers the one collector tool it needs; the agent supplies field values and + * the handler supplies the path, applies the schema legality gate, and writes. + * Two properties fall out of that: a file path cannot be hallucinated, and a + * verdict the schema forbids is rejected while the agent is still running and can + * fix it, rather than being dropped silently at export. + * + * Five of these six tools *mutate* an existing `findings/.json`; the finding + * evolves through the verdict ladder rather than being created whole. + * `report_finding` is the only creator. + * + * Rejection is deliberate over coercion. `cwe` is component #1 of the SAST dedup + * identity tuple, so a value this layer quietly "fixed" would re-key a canonical + * finding with no way to tell afterwards, and the verdict-legality gates + * (`assertReviewLegality`) become executable here rather than left to the model. + */ + +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent'; +import { Type } from 'typebox'; +import { + CALIBRATION_RULE_KEYS, + CAPELLA_ATTACKER_POSITIONS, + CAPELLA_AVAILABILITY_TIERS, + CAPELLA_CALIBRATION_OUTCOMES, + CAPELLA_CONFIRM_STATUSES, + CAPELLA_EXPOSURES, + CAPELLA_PRIVILEGES, + CAPELLA_REVIEW_STATUSES, + CAPELLA_SEVERITIES, + CAPELLA_TRIAGE_OUTCOMES, + CAPELLA_USER_INTERACTIONS, + CAPELLA_VIABILITIES, + type CalibrationChecklist, + type CalibrationRuleKey, + type CapellaCalibrationOutcome, + type CapellaConfirmStatus, + type CapellaFinding, + type CapellaHistoryEntry, + type CapellaReviewStatus, + type CapellaSeverity, + type CapellaTriageOutcome, + type CapellaViability, + TRIAGE_RULE_KEYS, + type TriageChecklist, + type TriageRuleKey, +} from './finding-types.js'; +import { isNormalizedRepositoryPath, parseCodePath } from './paths.js'; + +// === Result Helpers === + +function textResult(text: string) { + return { content: [{ type: 'text' as const, text }], details: undefined }; +} + +function accepted(payload: Record) { + return textResult(JSON.stringify({ success: true, ...payload })); +} + +/** + * A rejection the agent can act on. The message names the field and the rule, + * because the agent's only route to a valid record is re-reading its own + * evidence and calling again. + */ +function rejected(error: string) { + return textResult(JSON.stringify({ success: false, error })); +} + +interface VerdictCollectorConfig { + readonly findingsDir: string; + readonly expectedIds: readonly string[]; +} + +export interface VerdictRejectionCounts { + readonly unexpected: number; + readonly duplicate: number; +} + +interface VerdictCollectorIntegrity { + getAcceptedIds: () => string[]; + getRejectionCounts: () => VerdictRejectionCounts; +} + +interface VerdictTracker extends VerdictCollectorIntegrity { + check: (findingId: string) => string | undefined; + accept: (findingId: string) => void; +} + +function createVerdictTracker(expectedFindingIds: readonly string[]): VerdictTracker { + const expectedIds = new Set(expectedFindingIds); + const acceptedIds = new Set(); + let unexpected = 0; + let duplicate = 0; + return { + check(findingId: string): string | undefined { + if (!expectedIds.has(findingId)) { + unexpected += 1; + return 'finding_id is not expected in this stage.'; + } + if (acceptedIds.has(findingId)) { + duplicate += 1; + return 'finding_id already has an accepted verdict in this stage.'; + } + return undefined; + }, + accept(findingId: string): void { + acceptedIds.add(findingId); + }, + getAcceptedIds: () => [...acceptedIds], + getRejectionCounts: () => ({ unexpected, duplicate }), + }; +} + +// === Slugging === + +/** + * Slugify one id component. + * + * Every component of a finding id is slugified because the id becomes a + * filename: an un-slugged model-supplied value could carry a path separator or + * `..` and write outside the findings directory. + */ +export function slugComponent(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-|-$/g, ''); +} + +/** + * Build a finding's id. + * + * Deterministic on purpose: the same defect reported twice (say, by a Temporal + * retry re-discovering it) yields the same id and the same file, so the second + * write is a no-op rather than a duplicate finding. The id is scan-local; + * upstream's UUIDs are not used, and `ruleId` on the SARIF is the bare CWE. + */ +export function buildCapellaFindingId(args: { cwe: string; file: string; line: number; title: string }): string { + const cwe = slugComponent(args.cwe) || 'cwe-unknown'; + const file = slugComponent(args.file.replace(/^.*\//, '').replace(/\.[^.]+$/, '')) || 'file'; + const title = slugComponent(args.title).split('-').slice(0, 6).join('-') || 'finding'; + return `${cwe}--${file}--l${args.line}--${title}`; +} + +// === CWE validation === + +const CWE_PATTERN = /^CWE-\d+$/; + +/** + * The committed-secret CWE family, which this engine does not report: a + * separate secret-scanning pipeline runs over the same commit and already + * reports these, and because the two tools pick different CWEs for the same + * literal the identity tuple differs and deduplication cannot collapse them. + * + * Only literals committed to source are excluded. What the code *does* with a + * secret at runtime stays in scope, because a secret scanner cannot see a flow. + */ +export const SECRET_SCANNER_CWES = new Set([ + 'CWE-798', // Use of Hard-coded Credentials + 'CWE-259', // Use of Hard-coded Password + 'CWE-321', // Use of Hard-coded Cryptographic Key + 'CWE-256', // Plaintext Storage of a Password + 'CWE-260', // Password in Configuration File + 'CWE-547', // Use of Hard-coded, Security-relevant Constants +]); + +/** + * Normalize a CWE id, or explain why it cannot be one. + * + * Upstream types `cwe` as optional and `["string","null"]`; Capella makes it + * required and `^CWE-\d+$`, because it is component #1 of the dedup identity + * tuple. Trimming and upper-casing is the whole of the tolerance. + */ +export function normalizeCwe(raw: unknown): { ok: true; cwe: string } | { ok: false; error: string } { + const cwe = String(raw ?? '') + .trim() + .toUpperCase(); + if (!CWE_PATTERN.test(cwe)) { + return { + ok: false, + error: + `cwe must be a bare CWE identifier matching CWE- (got ${JSON.stringify(raw)}). ` + + 'It keys deduplication across every scan of this repository, so it cannot carry a name, ' + + 'a description or more than one id, and — unlike upstream — it is required here. ' + + 'Re-read the sink and supply the single best CWE.', + }; + } + if (SECRET_SCANNER_CWES.has(cwe)) { + return { + ok: false, + error: + `${cwe} is a committed-secret finding, which this engine does not report — a dedicated ` + + 'secret-scanning pipeline already covers this commit, and a second report of the same ' + + 'literal under a different CWE is a duplicate deduplication cannot collapse. ' + + 'If the real defect is what the code DOES with the secret at runtime (logs it, writes it ' + + 'to web storage, puts it in a URL), report that flow at its sink with the CWE for that ' + + 'behaviour. Otherwise drop it.', + }; + } + return { ok: true, cwe }; +} + +// === Enum + field validation === + +function validateEnum( + raw: unknown, + allowed: readonly T[], + field: string, +): { ok: true; value: T } | { ok: false; error: string } { + const value = typeof raw === 'string' ? raw.trim() : ''; + if (!(allowed as readonly string[]).includes(value)) { + return { ok: false, error: `${field} must be one of ${allowed.join(', ')} (got ${JSON.stringify(raw)})` }; + } + return { ok: true, value: value as T }; +} + +function requireNonEmpty(raw: unknown, field: string): { ok: true; value: string } | { ok: false; error: string } { + const value = typeof raw === 'string' ? raw.trim() : ''; + if (!value) { + return { ok: false, error: `${field} is required and must be non-empty` }; + } + return { ok: true, value }; +} + +/** + * Validate `code_paths` and resolve the primary location. + * + * `code_paths[0]` must be a sink locator `:`, never a URL or a + * bare symbol: the SARIF exporter reads it as `locations[0]`, and a non-file + * locator inserts with a NULL path and collapses with every other location-less + * finding on the same CWE. Upstream findings legitimately carry such locators, + * so this boundary is where they must be rejected. + */ +export function validateCodePaths( + raw: unknown, +): { ok: true; codePaths: string[]; file: string; line: number } | { ok: false; error: string } { + if (!Array.isArray(raw) || raw.length === 0) { + return { + ok: false, + error: 'code_paths must have at least one entry, and code_paths[0] must be the sink location as ":".', + }; + } + const codePaths = raw.map((p) => String(p ?? '').trim()).filter((p) => p.length > 0); + if (codePaths.length === 0) { + return { ok: false, error: 'code_paths must contain at least one non-empty ":" entry.' }; + } + const first = codePaths[0]; + if (!first) { + return { ok: false, error: 'code_paths must contain at least one non-empty ":" entry.' }; + } + if (first.includes('://')) { + return { + ok: false, + error: `code_paths[0] must be a sink location ":", not a URL (got ${JSON.stringify(first)}).`, + }; + } + const parsed = parseCodePath(first); + if (!parsed || !isNormalizedRepositoryPath(parsed.file)) { + return { + ok: false, + error: + `code_paths[0] must be a normalized repository-relative ":" with a positive line number ` + + `(got ${JSON.stringify(first)}). It becomes the finding's SARIF location, so it cannot be absolute, ` + + 'contain traversal, be a bare file, a symbol or an offset.', + }; + } + return { ok: true, codePaths, file: parsed.file, line: parsed.line }; +} + +// === Checklist validation === + +/** Validate the 13-rule triage checklist: every rule present, reason where required. */ +export function validateTriageChecklist( + raw: unknown, +): { ok: true; checklist: TriageChecklist } | { ok: false; error: string } { + const src = (raw ?? {}) as Record; + const checklist = {} as TriageChecklist; + for (const key of TRIAGE_RULE_KEYS) { + const entry = src[key] as { outcome?: unknown; reason?: unknown } | undefined; + if (!entry || typeof entry !== 'object') { + return { + ok: false, + error: `triage_checklist.${key} is required — all 13 negative constraints must be recorded.`, + }; + } + const outcome = typeof entry.outcome === 'string' ? entry.outcome.trim() : ''; + if (!(CAPELLA_TRIAGE_OUTCOMES as readonly string[]).includes(outcome)) { + return { + ok: false, + error: `triage_checklist.${key}.outcome must be one of ${CAPELLA_TRIAGE_OUTCOMES.join(', ')} (got ${JSON.stringify(entry.outcome)}).`, + }; + } + const reason = typeof entry.reason === 'string' ? entry.reason.trim() : ''; + const needsReason = outcome === 'FAIL' || outcome === 'UNKNOWN' || outcome === 'NOT_APPLICABLE'; + if (needsReason && !reason) { + return { ok: false, error: `triage_checklist.${key}.reason is required when outcome is ${outcome}.` }; + } + checklist[key as TriageRuleKey] = reason + ? { outcome: outcome as CapellaTriageOutcome, reason } + : { outcome: outcome as CapellaTriageOutcome }; + } + return { ok: true, checklist }; +} + +/** + * The verdict-legality gates, enforced in Node: a non-FALSE_POSITIVE verdict + * may carry no `FAIL`, and a `VALID` verdict may additionally carry no + * `UNKNOWN`. + * + * `FALSE_POSITIVE` is the only status a `FAIL` is legal alongside; that is the + * asymmetry the whole ladder rests on. + */ +export function assertReviewLegality(status: CapellaReviewStatus, checklist: TriageChecklist): string | null { + if (status !== 'FALSE_POSITIVE') { + for (const key of TRIAGE_RULE_KEYS) { + if (checklist[key].outcome === 'FAIL') { + return ( + `a ${status} finding cannot have triage_checklist.${key} = FAIL. FALSE_POSITIVE is the only ` + + 'status a FAIL is legal alongside. If a rule looks failed but you are not marking the ' + + 'finding FALSE_POSITIVE, use UNKNOWN with a reason instead.' + ); + } + } + } + if (status === 'VALID') { + for (const key of TRIAGE_RULE_KEYS) { + if (checklist[key].outcome === 'UNKNOWN') { + return ( + `a VALID finding cannot have triage_checklist.${key} = UNKNOWN — reaching VALID means every ` + + 'rule was affirmatively cleared (PASS or NOT_APPLICABLE). Use PROVISIONALLY_VALID if you are ' + + 'not certain of a rule.' + ); + } + } + } + return null; +} + +/** Validate the 27-rule calibration checklist: every rule present, reason where required. */ +export function validateCalibrationChecklist( + raw: unknown, +): { ok: true; checklist: CalibrationChecklist } | { ok: false; error: string } { + const src = (raw ?? {}) as Record; + const checklist = {} as CalibrationChecklist; + for (const key of CALIBRATION_RULE_KEYS) { + const entry = src[key] as { outcome?: unknown; reason?: unknown } | undefined; + if (!entry || typeof entry !== 'object') { + return { + ok: false, + error: `calibration_checklist.${key} is required — all 27 sanity-cap rules must be recorded.`, + }; + } + const outcome = typeof entry.outcome === 'string' ? entry.outcome.trim() : ''; + if (!(CAPELLA_CALIBRATION_OUTCOMES as readonly string[]).includes(outcome)) { + return { + ok: false, + error: `calibration_checklist.${key}.outcome must be one of ${CAPELLA_CALIBRATION_OUTCOMES.join(', ')} (got ${JSON.stringify(entry.outcome)}).`, + }; + } + const reason = typeof entry.reason === 'string' ? entry.reason.trim() : ''; + const needsReason = outcome === 'APPLIES' || outcome === 'UNKNOWN'; + if (needsReason && !reason) { + return { ok: false, error: `calibration_checklist.${key}.reason is required when outcome is ${outcome}.` }; + } + checklist[key as CalibrationRuleKey] = reason + ? { outcome: outcome as CapellaCalibrationOutcome, reason } + : { outcome: outcome as CapellaCalibrationOutcome }; + } + return { ok: true, checklist }; +} + +function validateScore( + raw: unknown, + field: string, + min: number, + max: number, +): { ok: true; value: number } | { ok: false; error: string } { + const value = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isFinite(value) || value < min || value > max) { + return { ok: false, error: `${field} must be a number between ${min} and ${max} (got ${JSON.stringify(raw)}).` }; + } + return { ok: true, value }; +} + +// === Finding I/O === + +/** Reject a finding id that could escape the findings directory. */ +function sanitizeFindingId(raw: unknown): { ok: true; id: string } | { ok: false; error: string } { + const id = String(raw ?? '').trim(); + if (!id) { + return { ok: false, error: 'finding_id is required.' }; + } + if (id.includes('/') || id.includes('\\') || id.includes('..')) { + return { + ok: false, + error: `finding_id ${JSON.stringify(id)} is not a bare finding id — it must not contain a path separator or "..".`, + }; + } + return { ok: true, id }; +} + +function readFinding( + findingsDir: string, + id: string, +): { ok: true; finding: CapellaFinding } | { ok: false; error: string } { + const path = join(findingsDir, `${id}.json`); + if (!existsSync(path)) { + return { + ok: false, + error: `no finding with id ${JSON.stringify(id)} exists. Read the findings directory and use an id from it.`, + }; + } + try { + return { ok: true, finding: JSON.parse(readFileSync(path, 'utf-8')) as CapellaFinding }; + } catch (error) { + return { ok: false, error: `finding ${JSON.stringify(id)} is corrupt: ${(error as Error).message}` }; + } +} + +function writeFinding(findingsDir: string, finding: CapellaFinding): void { + mkdirSync(findingsDir, { recursive: true }); + writeFileSync(join(findingsDir, `${finding.id}.json`), JSON.stringify(finding, null, 2), 'utf-8'); +} + +function historyEntry(stage: string, action: string, details: string): CapellaHistoryEntry { + return { stage, action, details, timestamp: new Date().toISOString() }; +} + +function deterministicFindingTimestamp(finding: CapellaFinding): string { + try { + return new Date(finding.recordedAt).toISOString(); + } catch { + return new Date(0).toISOString(); + } +} + +/** Quarantine review survivors that received no accepted verdict. */ +export function quarantineUngradedReviewFindings(findingsDir: string, findingIds: readonly string[]): number { + let quarantined = 0; + for (const findingId of [...new Set(findingIds)].sort()) { + const existing = readFinding(findingsDir, findingId); + if (!existing.ok) continue; + const finding = existing.finding; + const alreadyQuarantined = finding.history.some( + (entry) => entry.stage === 'review' && entry.action === 'quarantined_ungraded', + ); + if (alreadyQuarantined) continue; + writeFinding(findingsDir, { + ...finding, + status: 'NEEDS_RESEARCH', + history: [ + ...finding.history, + { + stage: 'review', + action: 'quarantined_ungraded', + details: 'No accepted review verdict was recorded.', + timestamp: deterministicFindingTimestamp(finding), + }, + ], + }); + quarantined += 1; + } + return quarantined; +} + +// === Shared schemas === + +const severitySchema = Type.Union( + CAPELLA_SEVERITIES.map((s) => Type.Literal(s)), + { + description: 'Severity estimate.', + }, +); + +const triageRuleSchema = Type.Object({ + outcome: Type.Union(CAPELLA_TRIAGE_OUTCOMES.map((o) => Type.Literal(o))), + reason: Type.Optional(Type.String({ description: 'Required when outcome is FAIL, UNKNOWN or NOT_APPLICABLE.' })), +}); + +const triageChecklistSchema = Type.Object(Object.fromEntries(TRIAGE_RULE_KEYS.map((k) => [k, triageRuleSchema])), { + description: 'All 13 negative constraints, each recorded PASS / FAIL / UNKNOWN / NOT_APPLICABLE.', +}); + +const calibrationRuleSchema = Type.Object({ + outcome: Type.Union(CAPELLA_CALIBRATION_OUTCOMES.map((o) => Type.Literal(o))), + reason: Type.Optional(Type.String({ description: 'Required when outcome is APPLIES or UNKNOWN.' })), +}); + +const calibrationChecklistSchema = Type.Object( + Object.fromEntries(CALIBRATION_RULE_KEYS.map((k) => [k, calibrationRuleSchema])), + { description: 'All 27 sanity-cap rules, each recorded APPLIES / DOES_NOT_APPLY / UNKNOWN.' }, +); + +// === Report Finding Collector (research wave 2) === + +export interface FindingCollector { + tools: ToolDefinition[]; + getFindings: () => CapellaFinding[]; +} + +/** + * `report_finding`: one call per candidate vulnerability. + * + * Creates `findings/.json` at `status: PROVISIONALLY_VALID`. Rejects a + * malformed CWE, a non-locator `code_paths[0]`, a bad enum or an empty required + * string, so a record that could not survive the verdict ladder never enters it. + */ +export function createFindingCollector(config: { findingsDir: string }): FindingCollector { + const findings: CapellaFinding[] = []; + + const tools: ToolDefinition[] = [ + defineTool({ + name: 'report_finding', + label: 'Report Finding', + description: + 'Record one candidate vulnerability. Call this once per finding you discover during the deep ' + + 'audit. The finding enters the pipeline at PROVISIONALLY_VALID and is validated downstream.', + parameters: Type.Object({ + title: Type.String({ description: 'Concise summary of the vulnerability.' }), + cwe: Type.String({ description: 'Bare CWE identifier, e.g. CWE-89. Required.' }), + severity: severitySchema, + code_paths: Type.Array(Type.String({ description: 'Source locator ":".' }), { + description: + 'Exact locations of the flaw, sink to source. code_paths[0] is the sink and becomes the SARIF location.', + }), + description: Type.String({ description: 'Detailed explanation of the flaw and its mechanism.' }), + impact: Type.String({ description: 'The potential consequence of the vulnerability.' }), + mitigation: Type.String({ description: 'Recommended corrective modification.' }), + attacker_position: Type.Union(CAPELLA_ATTACKER_POSITIONS.map((p) => Type.Literal(p))), + privileges_required: Type.Union(CAPELLA_PRIVILEGES.map((p) => Type.Literal(p))), + user_interaction: Type.Union(CAPELLA_USER_INTERACTIONS.map((u) => Type.Literal(u))), + }), + async execute(_toolCallId, params) { + const p = params as Record; + + const cwe = normalizeCwe(p.cwe); + if (!cwe.ok) return rejected(cwe.error); + + const severity = validateEnum(p.severity, CAPELLA_SEVERITIES, 'severity'); + if (!severity.ok) return rejected(severity.error); + + const location = validateCodePaths(p.code_paths); + if (!location.ok) return rejected(location.error); + + const title = requireNonEmpty(p.title, 'title'); + if (!title.ok) return rejected(title.error); + const description = requireNonEmpty(p.description, 'description'); + if (!description.ok) return rejected(description.error); + const impact = requireNonEmpty(p.impact, 'impact'); + if (!impact.ok) return rejected(impact.error); + const mitigation = requireNonEmpty(p.mitigation, 'mitigation'); + if (!mitigation.ok) return rejected(mitigation.error); + + const attackerPosition = validateEnum(p.attacker_position, CAPELLA_ATTACKER_POSITIONS, 'attacker_position'); + if (!attackerPosition.ok) return rejected(attackerPosition.error); + const privileges = validateEnum(p.privileges_required, CAPELLA_PRIVILEGES, 'privileges_required'); + if (!privileges.ok) return rejected(privileges.error); + const userInteraction = validateEnum(p.user_interaction, CAPELLA_USER_INTERACTIONS, 'user_interaction'); + if (!userInteraction.ok) return rejected(userInteraction.error); + + const id = buildCapellaFindingId({ + cwe: cwe.cwe, + file: location.file, + line: location.line, + title: title.value, + }); + if (findings.some((f) => f.id === id)) { + return accepted({ findingId: id, duplicate: true, totalFindings: findings.length }); + } + + const finding: CapellaFinding = { + id, + title: title.value, + description: description.value, + code_paths: location.codePaths, + impact: impact.value, + severity: severity.value as CapellaSeverity, + privileges_required: privileges.value, + attacker_position: attackerPosition.value, + user_interaction: userInteraction.value, + mitigation: mitigation.value, + cwe: cwe.cwe, + history: [historyEntry('researcher', 'created', `Discovered at ${location.file}:${location.line}`)], + status: 'PROVISIONALLY_VALID', + recordedAt: Date.now(), + }; + + writeFinding(config.findingsDir, finding); + findings.push(finding); + return accepted({ findingId: id, totalFindings: findings.length }); + }, + }), + ]; + + return { tools, getFindings: () => [...findings] }; +} + +// === Record Duplicates Collector (dedupe) === + +export interface DuplicateRecord { + id: string; + duplicateOf: string; +} + +export interface DuplicateCollector { + tools: ToolDefinition[]; + getDuplicates: () => DuplicateRecord[]; +} + +/** + * `record_duplicates`: mark one finding a duplicate of another. + * + * Sets `status: DUPLICATE` plus `duplicate_of` and moves the duplicate to + * `.trash/`. Refuses a merge that would leave the survivor without a CWE, + * because on a merge upstream copies `cwe` from the primary only, and a + * CWE-less survivor breaks the identity tuple. + * + * NOTE: unlike upstream, this deliberately does NOT field-merge the duplicate + * into the primary: no `code_paths` union, no `attacker_position`/ + * `privileges_required`/`user_interaction` max, no `description`/`impact` + * concatenation. The primary survives byte-for-byte and the duplicate is trashed. + * The collector has no way to rewrite the survivor's fields, and asking the agent + * to reconcile them is the deterministic-work-in-an-agent anti-pattern. The + * tradeoff: a trashed duplicate that carried a stricter `attacker_position` or an + * extra sink loses that data. Accepted because the finder over-reports per + * investigation and the prompt selects the more comprehensive record as primary. + */ +export function createDuplicateCollector(config: { findingsDir: string }): DuplicateCollector { + const duplicates: DuplicateRecord[] = []; + const trashDir = join(config.findingsDir, '.trash'); + + const tools: ToolDefinition[] = [ + defineTool({ + name: 'record_duplicates', + label: 'Record Duplicate', + description: + 'Mark one finding as a duplicate of another (the primary it survives under). Call this once ' + + 'per duplicate. Findings at different lines in the same file are DISTINCT — never merge them.', + parameters: Type.Object({ + duplicate_id: Type.String({ description: 'The id of the finding to retire as a duplicate.' }), + primary_id: Type.String({ description: 'The id of the primary finding it duplicates.' }), + reason: Type.Optional(Type.String({ description: 'Why these are the same defect.' })), + }), + async execute(_toolCallId, params) { + const p = params as Record; + + const dupId = sanitizeFindingId(p.duplicate_id); + if (!dupId.ok) return rejected(`duplicate_id: ${dupId.error}`); + const primId = sanitizeFindingId(p.primary_id); + if (!primId.ok) return rejected(`primary_id: ${primId.error}`); + if (dupId.id === primId.id) { + return rejected('duplicate_id and primary_id must differ — a finding cannot be a duplicate of itself.'); + } + + const dup = readFinding(config.findingsDir, dupId.id); + if (!dup.ok) return rejected(`duplicate_id: ${dup.error}`); + const primary = readFinding(config.findingsDir, primId.id); + if (!primary.ok) return rejected(`primary_id: ${primary.error}`); + + if (!CWE_PATTERN.test(String(primary.finding.cwe ?? '').toUpperCase())) { + return rejected( + `primary finding ${JSON.stringify(primId.id)} has no valid CWE, so merging into it would leave the ` + + 'survivor without one. Pick a primary that carries a CWE.', + ); + } + + const reason = typeof p.reason === 'string' ? p.reason.trim() : ''; + const updated: CapellaFinding = { + ...dup.finding, + status: 'DUPLICATE', + duplicate_of: primId.id, + history: [ + ...dup.finding.history, + historyEntry('dedupe', 'marked_duplicate', reason || `Duplicate of ${primId.id}`), + ], + }; + + // Move to .trash: write the retired record there, then remove the original. + mkdirSync(trashDir, { recursive: true }); + writeFileSync(join(trashDir, `${updated.id}.json`), JSON.stringify(updated, null, 2), 'utf-8'); + rmSync(join(config.findingsDir, `${updated.id}.json`), { force: true }); + + duplicates.push({ id: dupId.id, duplicateOf: primId.id }); + return accepted({ findingId: dupId.id, duplicateOf: primId.id, totalDuplicates: duplicates.length }); + }, + }), + ]; + + return { tools, getDuplicates: () => [...duplicates] }; +} + +// === Record Review Verdict Collector (review) === + +export interface ReviewRecord { + id: string; + status: CapellaReviewStatus; +} + +export interface ReviewCollector extends VerdictCollectorIntegrity { + tools: ToolDefinition[]; + getVerdicts: () => ReviewRecord[]; +} + +/** + * `record_review_verdict`: record the 13-rule verdict for one finding. + * + * Enforces the two verdict-legality gates in Node (`assertReviewLegality`): a + * non-FALSE_POSITIVE verdict may carry no `FAIL`, and a `VALID` verdict may carry + * no `UNKNOWN`. `DUPLICATE` is not assignable here; only dedupe assigns it. + */ +export function createReviewCollector(config: VerdictCollectorConfig): ReviewCollector { + const verdicts: ReviewRecord[] = []; + const guard = createVerdictTracker(config.expectedIds); + + const tools: ToolDefinition[] = [ + defineTool({ + name: 'record_review_verdict', + label: 'Record Review Verdict', + description: + 'Record the adversarial review verdict for one finding: its status, your independent reasoning, ' + + 'and the 13-rule triage checklist. Assume the finding is a false positive until the code ' + + 'disproves it.', + parameters: Type.Object({ + finding_id: Type.String({ description: 'The id of the finding being reviewed.' }), + status: Type.Union(CAPELLA_REVIEW_STATUSES.map((s) => Type.Literal(s))), + reasoning: Type.String({ description: 'Your independent rationale for the status, based only on the code.' }), + triage_checklist: triageChecklistSchema, + repro_hints: Type.Optional( + Type.String({ description: 'How to trigger the bug, and the trust-boundary ingress point.' }), + ), + }), + async execute(_toolCallId, params) { + const p = params as Record; + + const findingId = sanitizeFindingId(p.finding_id); + if (!findingId.ok) return rejected(`finding_id: ${findingId.error}`); + + const status = validateEnum(p.status, CAPELLA_REVIEW_STATUSES, 'status'); + if (!status.ok) return rejected(status.error); + + const reasoning = requireNonEmpty(p.reasoning, 'reasoning'); + if (!reasoning.ok) return rejected(reasoning.error); + + const checklist = validateTriageChecklist(p.triage_checklist); + if (!checklist.ok) return rejected(checklist.error); + + const legalityError = assertReviewLegality(status.value, checklist.checklist); + if (legalityError) return rejected(legalityError); + + const integrityError = guard.check(findingId.id); + if (integrityError) return rejected(integrityError); + + const existing = readFinding(config.findingsDir, findingId.id); + if (!existing.ok) return rejected(`finding_id: ${existing.error}`); + + const reproHints = typeof p.repro_hints === 'string' ? p.repro_hints.trim() : ''; + const updated: CapellaFinding = { + ...existing.finding, + status: status.value, + reasoning: reasoning.value, + triage_checklist: checklist.checklist, + ...(reproHints ? { repro_hints: reproHints } : {}), + history: [...existing.finding.history, historyEntry('reviewer', 'reviewed', `status=${status.value}`)], + }; + + writeFinding(config.findingsDir, updated); + verdicts.push({ id: findingId.id, status: status.value }); + guard.accept(findingId.id); + return accepted({ findingId: findingId.id, status: status.value, totalVerdicts: verdicts.length }); + }, + }), + ]; + + return { + tools, + getVerdicts: () => [...verdicts], + getAcceptedIds: guard.getAcceptedIds, + getRejectionCounts: guard.getRejectionCounts, + }; +} + +// === Record Viability Collector (critic) === + +export interface ViabilityRecord { + id: string; + viability: CapellaViability; +} + +export interface ViabilityCollector extends VerdictCollectorIntegrity { + tools: ToolDefinition[]; + getViabilities: () => ViabilityRecord[]; +} + +/** + * `record_viability`: record the production-viability verdict for one finding. + * + * On drift or a missing file the critic is *required* to return + * `CONDITIONAL_VIABLE`, never `NON_VIABLE`; that discipline lives in the + * prompt, and this tool only records the value. + */ +export function createViabilityCollector(config: VerdictCollectorConfig): ViabilityCollector { + const viabilities: ViabilityRecord[] = []; + const guard = createVerdictTracker(config.expectedIds); + + const tools: ToolDefinition[] = [ + defineTool({ + name: 'record_viability', + label: 'Record Viability', + description: + 'Record whether a validated finding remains triggerable in a production release build. Call ' + + 'this once per finding you assess.', + parameters: Type.Object({ + finding_id: Type.String({ description: 'The id of the finding being assessed.' }), + production_viability: Type.Union(CAPELLA_VIABILITIES.map((v) => Type.Literal(v))), + critic_reasoning: Type.String({ description: 'Rationale for the viability verdict.' }), + }), + async execute(_toolCallId, params) { + const p = params as Record; + + const findingId = sanitizeFindingId(p.finding_id); + if (!findingId.ok) return rejected(`finding_id: ${findingId.error}`); + + const viability = validateEnum(p.production_viability, CAPELLA_VIABILITIES, 'production_viability'); + if (!viability.ok) return rejected(viability.error); + + const reasoning = requireNonEmpty(p.critic_reasoning, 'critic_reasoning'); + if (!reasoning.ok) return rejected(reasoning.error); + + const integrityError = guard.check(findingId.id); + if (integrityError) return rejected(integrityError); + + const existing = readFinding(config.findingsDir, findingId.id); + if (!existing.ok) return rejected(`finding_id: ${existing.error}`); + + const updated: CapellaFinding = { + ...existing.finding, + production_viability: viability.value as CapellaViability, + critic_reasoning: reasoning.value, + history: [...existing.finding.history, historyEntry('critic', 'assessed', `viability=${viability.value}`)], + }; + + writeFinding(config.findingsDir, updated); + viabilities.push({ id: findingId.id, viability: viability.value as CapellaViability }); + guard.accept(findingId.id); + return accepted({ findingId: findingId.id, viability: viability.value, totalAssessed: viabilities.length }); + }, + }), + ]; + + return { + tools, + getViabilities: () => [...viabilities], + getAcceptedIds: guard.getAcceptedIds, + getRejectionCounts: guard.getRejectionCounts, + }; +} + +// === Record Static Confirmation Collector (confirm) === + +export interface ConfirmationRecord { + id: string; + reproStatus: CapellaConfirmStatus; + promoted: boolean; +} + +export interface ConfirmationCollector extends VerdictCollectorIntegrity { + tools: ToolDefinition[]; + getConfirmations: () => ConfirmationRecord[]; +} + +/** + * `record_static_confirmation`: record the static confirmation for one finding + * and apply the `PROVISIONALLY_VALID -> VALID` promotion. + * + * Capella has no execution sandbox, so the only classifications it can set are + * `statically_confirmed` and `not_attempted`. A `statically_confirmed` finding + * that is currently `PROVISIONALLY_VALID` is promoted to `VALID`, the export + * gate, unless its review checklist carries an `UNKNOWN` or `FAIL`, which would + * violate the rule that a VALID finding carries no UNKNOWN or FAIL. + */ +export function createConfirmationCollector(config: VerdictCollectorConfig): ConfirmationCollector { + const confirmations: ConfirmationRecord[] = []; + const guard = createVerdictTracker(config.expectedIds); + + const tools: ToolDefinition[] = [ + defineTool({ + name: 'record_static_confirmation', + label: 'Record Static Confirmation', + description: + 'Record the static confirmation for one finding. Use "statically_confirmed" when the flaw is ' + + 'statically obvious from the source and the reached-sink evidence is present; use ' + + '"not_attempted" otherwise. A statically-confirmed PROVISIONALLY_VALID finding is promoted to VALID.', + parameters: Type.Object({ + finding_id: Type.String({ description: 'The id of the finding being confirmed.' }), + repro_status: Type.Union(CAPELLA_CONFIRM_STATUSES.map((s) => Type.Literal(s))), + repro_hints: Type.Optional( + Type.String({ description: 'The reached-sink evidence supporting the confirmation.' }), + ), + }), + async execute(_toolCallId, params) { + const p = params as Record; + + const findingId = sanitizeFindingId(p.finding_id); + if (!findingId.ok) return rejected(`finding_id: ${findingId.error}`); + + const reproStatus = validateEnum(p.repro_status, CAPELLA_CONFIRM_STATUSES, 'repro_status'); + if (!reproStatus.ok) return rejected(reproStatus.error); + + const integrityError = guard.check(findingId.id); + if (integrityError) return rejected(integrityError); + + const existing = readFinding(config.findingsDir, findingId.id); + if (!existing.ok) return rejected(`finding_id: ${existing.error}`); + const finding = existing.finding; + + const checklist = finding.triage_checklist; + const checklistBlocksPromotion = + checklist !== undefined && + TRIAGE_RULE_KEYS.some((key) => checklist[key]?.outcome === 'UNKNOWN' || checklist[key]?.outcome === 'FAIL'); + + const promoted = + reproStatus.value === 'statically_confirmed' && + finding.status === 'PROVISIONALLY_VALID' && + !checklistBlocksPromotion; + + const reproHints = typeof p.repro_hints === 'string' ? p.repro_hints.trim() : ''; + const updated: CapellaFinding = { + ...finding, + repro_status: reproStatus.value, + ...(reproHints ? { repro_hints: reproHints } : {}), + ...(promoted ? { status: 'VALID' as const } : {}), + history: [ + ...finding.history, + historyEntry( + 'confirm', + promoted ? 'promoted_to_valid' : 'confirmed', + `repro_status=${reproStatus.value}${promoted ? ' (PROVISIONALLY_VALID -> VALID)' : ''}`, + ), + ], + }; + + writeFinding(config.findingsDir, updated); + confirmations.push({ id: findingId.id, reproStatus: reproStatus.value, promoted }); + guard.accept(findingId.id); + return accepted({ + findingId: findingId.id, + reproStatus: reproStatus.value, + promoted, + totalConfirmed: confirmations.length, + }); + }, + }), + ]; + + return { + tools, + getConfirmations: () => [...confirmations], + getAcceptedIds: guard.getAcceptedIds, + getRejectionCounts: guard.getRejectionCounts, + }; +} + +// === Record Calibration Collector (calibrate, report-only) === + +export interface CalibrationRecord { + id: string; + riskScore: number; +} + +export interface CalibrationCollector extends VerdictCollectorIntegrity { + tools: ToolDefinition[]; + getCalibrations: () => CalibrationRecord[]; +} + +/** + * `record_calibration`: record the report-only risk calibration for one finding. + * + * This never changes the finding's `status`, its exported `severity` or the + * export gate: the score and the fired sanity caps are read only by `report.md`. + * It exists so an operator can see what the calibration would have said. + */ +export function createCalibrationCollector(config: VerdictCollectorConfig): CalibrationCollector { + const calibrations: CalibrationRecord[] = []; + const guard = createVerdictTracker(config.expectedIds); + + const tools: ToolDefinition[] = [ + defineTool({ + name: 'record_calibration', + label: 'Record Calibration', + description: + 'Record the report-only risk calibration for one finding: its risk score, the sanity caps that ' + + "fired, and the 27-rule calibration checklist. This does not change the finding's severity or " + + 'whether it is exported — it is shown in the report for operator context.', + parameters: Type.Object({ + finding_id: Type.String({ description: 'The id of the finding being calibrated.' }), + impact_score: Type.Number({ description: 'Technical impact on the CIA triad, 1-5.' }), + likelihood_score: Type.Number({ description: 'Probability of occurrence, 1-5.' }), + mantis_risk_score: Type.Number({ description: 'Final calculated risk score, 0.1-10.' }), + priority: severitySchema, + sanity_triage_applied: Type.Optional( + Type.String({ description: 'Semicolon-separated list of caps/downgrades that fired, or empty.' }), + ), + availability_tier: Type.Optional(Type.Union(CAPELLA_AVAILABILITY_TIERS.map((t) => Type.Literal(t)))), + inferred_exposure: Type.Optional(Type.Union(CAPELLA_EXPOSURES.map((e) => Type.Literal(e)))), + calibration_checklist: calibrationChecklistSchema, + }), + async execute(_toolCallId, params) { + const p = params as Record; + + const findingId = sanitizeFindingId(p.finding_id); + if (!findingId.ok) return rejected(`finding_id: ${findingId.error}`); + + const impactScore = validateScore(p.impact_score, 'impact_score', 1, 5); + if (!impactScore.ok) return rejected(impactScore.error); + const likelihoodScore = validateScore(p.likelihood_score, 'likelihood_score', 1, 5); + if (!likelihoodScore.ok) return rejected(likelihoodScore.error); + const riskScore = validateScore(p.mantis_risk_score, 'mantis_risk_score', 0.1, 10); + if (!riskScore.ok) return rejected(riskScore.error); + + const priority = validateEnum(p.priority, CAPELLA_SEVERITIES, 'priority'); + if (!priority.ok) return rejected(priority.error); + + const checklist = validateCalibrationChecklist(p.calibration_checklist); + if (!checklist.ok) return rejected(checklist.error); + + let availabilityTier: (typeof CAPELLA_AVAILABILITY_TIERS)[number] | undefined; + if (p.availability_tier !== undefined && p.availability_tier !== null) { + const tier = validateEnum(p.availability_tier, CAPELLA_AVAILABILITY_TIERS, 'availability_tier'); + if (!tier.ok) return rejected(tier.error); + availabilityTier = tier.value; + } + let inferredExposure: (typeof CAPELLA_EXPOSURES)[number] | undefined; + if (p.inferred_exposure !== undefined) { + const exposure = validateEnum(p.inferred_exposure, CAPELLA_EXPOSURES, 'inferred_exposure'); + if (!exposure.ok) return rejected(exposure.error); + inferredExposure = exposure.value; + } + + const integrityError = guard.check(findingId.id); + if (integrityError) return rejected(integrityError); + + const existing = readFinding(config.findingsDir, findingId.id); + if (!existing.ok) return rejected(`finding_id: ${existing.error}`); + + const sanityApplied = typeof p.sanity_triage_applied === 'string' ? p.sanity_triage_applied.trim() : ''; + const updated: CapellaFinding = { + ...existing.finding, + impact_score: impactScore.value, + likelihood_score: likelihoodScore.value, + mantis_risk_score: riskScore.value, + priority: priority.value as CapellaSeverity, + sanity_triage_applied: sanityApplied || null, + ...(availabilityTier ? { availability_tier: availabilityTier } : {}), + ...(inferredExposure ? { inferred_exposure: inferredExposure } : {}), + calibration_checklist: checklist.checklist, + history: [...existing.finding.history, historyEntry('calibrate', 'calibrated', `risk=${riskScore.value}`)], + }; + + writeFinding(config.findingsDir, updated); + calibrations.push({ id: findingId.id, riskScore: riskScore.value }); + guard.accept(findingId.id); + return accepted({ findingId: findingId.id, riskScore: riskScore.value, totalCalibrated: calibrations.length }); + }, + }), + ]; + + return { + tools, + getCalibrations: () => [...calibrations], + getAcceptedIds: guard.getAcceptedIds, + getRejectionCounts: guard.getRejectionCounts, + }; +} diff --git a/apps/worker/src/ai/sast/capella/error-contract.ts b/apps/worker/src/ai/sast/capella/error-contract.ts new file mode 100644 index 00000000..a49fc6d9 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/error-contract.ts @@ -0,0 +1,45 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** Workflow-safe error identities shared by the Capella executor and Temporal policy. */ + +export const CAPELLA_AGENT_ERROR_NAMES = Object.freeze([ + 'AgentExecutionError', + 'AuthenticationError', + 'ConfigurationError', + 'InvalidInputError', + 'SastContractError', +] as const); + +export type CapellaAgentErrorName = (typeof CAPELLA_AGENT_ERROR_NAMES)[number]; + +/** + * Temporal's type-level retry gate. Activity classification separately forwards each error + * instance's retryability, so `AgentExecutionError` is not a blanket retry guarantee. + * + * `AgentExecutionError` is the one name marked retryable at the type level: it covers transient + * failures (provider hiccups, transport faults) that a retry can plausibly clear. The rest name + * problems a retry cannot fix on its own: bad credentials, bad configuration, bad input, or a + * contract violation in Capella's own output. + */ +export const CAPELLA_ERROR_TYPE_NON_RETRYABLE = Object.freeze({ + AgentExecutionError: false, + AuthenticationError: true, + ConfigurationError: true, + InvalidInputError: true, + SastContractError: true, +} as const satisfies Readonly>); + +export const CAPELLA_NON_RETRYABLE_ERROR_TYPES = Object.freeze( + CAPELLA_AGENT_ERROR_NAMES.filter((name) => CAPELLA_ERROR_TYPE_NON_RETRYABLE[name]), +); + +// Compile-time exhaustiveness check: if a name is ever added to CAPELLA_AGENT_ERROR_NAMES without +// a matching entry in CAPELLA_ERROR_TYPE_NON_RETRYABLE, UnclassifiedCapellaAgentError stops being +// `never` and this assignment fails to typecheck, catching the gap before it reaches Temporal. +type UnclassifiedCapellaAgentError = Exclude; +const _everyCapellaAgentErrorHasTypeGate: UnclassifiedCapellaAgentError extends never ? true : never = true; +void _everyCapellaAgentErrorHasTypeGate; diff --git a/apps/worker/src/ai/sast/capella/errors.ts b/apps/worker/src/ai/sast/capella/errors.ts new file mode 100644 index 00000000..64bf72e0 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/errors.ts @@ -0,0 +1,69 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import type { ProviderFailureCategory } from '../../../types/errors.js'; + +const MAX_ERROR_MESSAGE_LENGTH = 2_000; +const MACHINE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; + +class NamedCapellaError extends Error { + constructor( + name: string, + readonly code: string, + message: string, + ) { + super(message.slice(0, MAX_ERROR_MESSAGE_LENGTH)); + this.name = name; + } +} + +/** A caller supplied an invalid repository, path, artifact, or stage input. */ +export class InvalidInputError extends NamedCapellaError { + constructor(message: string, code = 'INVALID_INPUT') { + super('InvalidInputError', code, message); + } +} + +/** A Capella format, stage, or SARIF invariant was violated. */ +export class SastContractError extends NamedCapellaError { + constructor(message: string, code = 'SAST_CONTRACT') { + super('SastContractError', code, message); + } +} + +/** A required Capella prompt or immutable setting is unavailable. */ +export class ConfigurationError extends NamedCapellaError { + constructor(message: string, code = 'CONFIGURATION') { + super('ConfigurationError', code, message); + } +} + +/** A sanitized local failure that is safe to retry without exposing its underlying I/O error. */ +export class CapellaRetryableError extends NamedCapellaError { + constructor(message: string, code = 'RETRYABLE_IO') { + super('CapellaRetryableError', code, message); + } +} + +/** Return a bounded machine code, never an error message or provider-authored value. */ +export function capellaFailureCode(error: unknown, fallback: string): string { + if (!error || typeof error !== 'object' || !('code' in error)) return fallback; + const code = (error as { readonly code?: unknown }).code; + return typeof code === 'string' && MACHINE_CODE_PATTERN.test(code) ? code : fallback; +} + +/** + * The most specific bounded code for a classified agent failure. A provider category names a + * real cause worth preferring (rate_limit, quota, context_limit, ...), but the vocabulary's + * `unknown` member names nothing, so a concrete fixed code outranks it. + */ +export function capellaClassifiedFailureCode( + code: string, + providerCategory: ProviderFailureCategory | undefined, +): string { + if (providerCategory === undefined || providerCategory === 'unknown') return code; + return providerCategory; +} diff --git a/apps/worker/src/ai/sast/capella/finding-types.ts b/apps/worker/src/ai/sast/capella/finding-types.ts new file mode 100644 index 00000000..77d98d65 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/finding-types.ts @@ -0,0 +1,227 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * Capella's on-disk record shapes and vocabularies. + * + * A transcription of the upstream Mantis finding contract, minus the + * fields for stages Capella does not run (patch, reattack, chain, and the + * whole snapshot/provenance layer). The calibrate surface is retained because + * calibrate is kept report-only. + * + * The vocabularies are exported as `readonly` arrays so the collector schemas and + * the Node-side validators share one source of truth; drift between the two is + * how an agent ships a value the schema forbids. + */ + +// === Enumerated vocabularies (upstream finding contract) === + +export const CAPELLA_SEVERITIES = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const; +export type CapellaSeverity = (typeof CAPELLA_SEVERITIES)[number]; + +export const CAPELLA_STATUSES = [ + 'VALID', + 'FALSE_POSITIVE', + 'PROVISIONALLY_VALID', + 'NEEDS_RESEARCH', + 'DUPLICATE', +] as const; +export type CapellaStatus = (typeof CAPELLA_STATUSES)[number]; + +/** + * The statuses a review verdict may assign. `DUPLICATE` is excluded: only + * dedupe assigns it, through a different tool. + */ +export const CAPELLA_REVIEW_STATUSES = ['VALID', 'FALSE_POSITIVE', 'PROVISIONALLY_VALID', 'NEEDS_RESEARCH'] as const; +export type CapellaReviewStatus = (typeof CAPELLA_REVIEW_STATUSES)[number]; + +export const CAPELLA_VIABILITIES = ['VIABLE', 'NON_VIABLE', 'SAMPLE_OR_TEST', 'CONDITIONAL_VIABLE'] as const; +export type CapellaViability = (typeof CAPELLA_VIABILITIES)[number]; + +/** + * The full upstream `repro_status` enum, kept for record fidelity. Capella can + * only ever *set* `statically_confirmed` or `not_attempted`: it has no + * execution sandbox, so `reproduced` and `failed_to_reproduce` are unreachable. + */ +export const CAPELLA_REPRO_STATUSES = [ + 'reproduced', + 'statically_confirmed', + 'not_attempted', + 'failed_to_reproduce', +] as const; +export type CapellaReproStatus = (typeof CAPELLA_REPRO_STATUSES)[number]; + +/** The classifications the static-confirmation stage may assign. */ +export const CAPELLA_CONFIRM_STATUSES = ['statically_confirmed', 'not_attempted'] as const; +export type CapellaConfirmStatus = (typeof CAPELLA_CONFIRM_STATUSES)[number]; + +export const CAPELLA_PRIVILEGES = ['NONE', 'LOW', 'HIGH'] as const; +export type CapellaPrivileges = (typeof CAPELLA_PRIVILEGES)[number]; + +export const CAPELLA_ATTACKER_POSITIONS = [ + 'EXTERNAL', + 'INTERNAL_NETWORK', + 'IN_CLUSTER', + 'LOCAL', + 'HOST_SYSTEM', + 'SUPPLY_CHAIN', + 'PHYSICAL_TEMPORARY', + 'PHYSICAL_LONG_TERM', +] as const; +export type CapellaAttackerPosition = (typeof CAPELLA_ATTACKER_POSITIONS)[number]; + +export const CAPELLA_USER_INTERACTIONS = ['NONE', 'REQUIRED'] as const; +export type CapellaUserInteraction = (typeof CAPELLA_USER_INTERACTIONS)[number]; + +export const CAPELLA_AVAILABILITY_TIERS = ['CRITICAL', 'STANDARD', 'LOW_CRITICALITY'] as const; +export type CapellaAvailabilityTier = (typeof CAPELLA_AVAILABILITY_TIERS)[number]; + +export const CAPELLA_EXPOSURES = ['EXPOSED', 'INTERNAL', 'PRIVILEGED'] as const; +export type CapellaExposure = (typeof CAPELLA_EXPOSURES)[number]; + +// === Checklists === + +export const CAPELLA_TRIAGE_OUTCOMES = ['PASS', 'FAIL', 'UNKNOWN', 'NOT_APPLICABLE'] as const; +export type CapellaTriageOutcome = (typeof CAPELLA_TRIAGE_OUTCOMES)[number]; + +/** The 13 negative constraints, in schema order. */ +export const TRIAGE_RULE_KEYS = [ + 'ignore_hypothetical_misuse', + 'ignore_missing_hygiene', + 'require_strict_reproducibility', + 'avoid_pedantic_linting', + 'no_security_flaw_stretching', + 'evaluate_questionable_file_paths', + 'ignore_resource_exhaustion_dos', + 'intrinsic_security_flaws', + 'verify_mitigations_pragmatically', + 'refine_code_paths_strictly', + 'ignore_simd_vector_padding', + 'ensure_source_code_coherence', + 'verify_attacker_control_of_source', +] as const; +export type TriageRuleKey = (typeof TRIAGE_RULE_KEYS)[number]; + +export interface TriageRuleEvaluation { + outcome: CapellaTriageOutcome; + /** Required whenever outcome is FAIL, UNKNOWN or NOT_APPLICABLE. */ + reason?: string; +} + +export type TriageChecklist = Record; + +export const CAPELLA_CALIBRATION_OUTCOMES = ['APPLIES', 'DOES_NOT_APPLY', 'UNKNOWN'] as const; +export type CapellaCalibrationOutcome = (typeof CAPELLA_CALIBRATION_OUTCOMES)[number]; + +/** The 27 sanity-cap rules, in schema order. */ +export const CALIBRATION_RULE_KEYS = [ + 'repro_failure', + 'unreachable_inputs', + 'third_party_reachability', + 'minor_config_hygiene', + 'non_security_critical', + 'vague_code_paths', + 'unreliable_triggers', + 'prerequisite_shell', + 'physical_long_term', + 'trusted_controller_zero_delta', + 'standard_host_attacks', + 'static_confirmation', + 'strict_xss', + 'internal_nested', + 'probabilistic_llm', + 'supply_chain_prerequisites', + 'non_default_config', + 'confidential_computing_host', + 'trusted_controller_critical_bypass', + 'local_attack_vector', + 'self_contained_blast', + 'rarely_exposed', + 'equivalent_primitives', + 'documented_insecure_config', + 'physical_temporary', + 'high_privilege_external', + 'trusted_controller_standard_bypass', +] as const; +export type CalibrationRuleKey = (typeof CALIBRATION_RULE_KEYS)[number]; + +export interface CalibrationRuleEvaluation { + outcome: CapellaCalibrationOutcome; + /** Required whenever outcome is APPLIES or UNKNOWN. */ + reason?: string; +} + +export type CalibrationChecklist = Record; + +// === History === + +/** + * One entry in a finding's audit trail. Simplified from the upstream + * `history_entry`: there is no multi-pass loop, so no `pass_number`, and Node + * writes the entries so the shape is ours to set. + */ +export interface CapellaHistoryEntry { + stage: string; + action: string; + details: string; + timestamp: string; +} + +// === The finding record === + +/** + * The Capella finding record (`findings/.json`). + * + * A subset of Mantis's `finding` object: the patch, reattack, chain and + * snapshot/provenance fields are dropped with their stages, and `outrage_commentary` + * / `executive_summary` go with the dropped report stage. The calibrate fields + * are kept because calibrate is retained report-only. + */ +export interface CapellaFinding { + // Identity + creation (researcher) + id: string; + title: string; + description: string; + code_paths: string[]; + impact: string; + severity: CapellaSeverity; + privileges_required: CapellaPrivileges; + attacker_position: CapellaAttackerPosition; + user_interaction: CapellaUserInteraction; + mitigation: string; + /** Required here where upstream makes it optional: it keys the dedup identity. */ + cwe: string; + history: CapellaHistoryEntry[]; + status: CapellaStatus; + + // Dedupe + duplicate_of?: string; + + // Review + reasoning?: string; + repro_hints?: string; + triage_checklist?: TriageChecklist; + + // Critic + production_viability?: CapellaViability; + critic_reasoning?: string; + + // Confirm (static only) + repro_status?: CapellaReproStatus; + + // Calibrate (report-only) + impact_score?: number; + likelihood_score?: number; + availability_tier?: CapellaAvailabilityTier | null; + inferred_exposure?: CapellaExposure; + mantis_risk_score?: number; + priority?: CapellaSeverity; + sanity_triage_applied?: string | null; + calibration_checklist?: CalibrationChecklist; + + // Bookkeeping (ours, not upstream's) + recordedAt: number; +} diff --git a/apps/worker/src/ai/sast/capella/paths.ts b/apps/worker/src/ai/sast/capella/paths.ts new file mode 100644 index 00000000..cf58de32 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/paths.ts @@ -0,0 +1,63 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import path from 'node:path'; + +export interface ParsedCodePath { + readonly file: string; + readonly line: number; +} + +/** Parse a sink-first `file:line` locator without coercing invalid paths. */ +export function parseCodePath(entry: string): ParsedCodePath | undefined { + const match = entry.trim().match(/^(.+):(\d+)$/); + if (!match) return undefined; + const file = match[1]; + const line = Number(match[2]); + if (!file || !Number.isSafeInteger(line) || line <= 0) return undefined; + return { file, line }; +} + +/** + * The normalized repository-relative POSIX path contract shared by SARIF + * locations and code-path scoping. Rejects absolute, drive-letter, encoded, + * traversal, and non-canonical forms so the same string keys comparisons on + * both the producing and consuming side. + */ +export function isNormalizedRepositoryPath(value: string): boolean { + if (!value || value.includes('\\') || value.includes('\0') || value.includes('://')) return false; + if (/%(?:00|2e|2f|5c)/i.test(value)) return false; + if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return false; + if (value.startsWith('./') || value.endsWith('/') || value.includes('//')) return false; + const segments = value.split('/'); + if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) return false; + return path.posix.normalize(value) === value; +} + +function escapeRegExp(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); +} + +// `**` collapses to a plain `*` before compiling: this matcher only needs to decide membership +// under an excluded directory, not distinguish depth, so a simpler wildcard is equivalent here. +function globExpression(pattern: string): RegExp { + const normalized = pattern.replace(/^(?:\.\.?\/)+/, '').replace(/\*\*/g, '*'); + const expression = escapeRegExp(normalized).replace(/\*/g, '.*').replace(/\\\?/g, '.'); + return new RegExp(`^(?:${expression}|.*/${expression})(?:/.*)?$`); +} + +/** Match a repository-relative path against normalized code_path exclusions. */ +export function isExcludedCodePath(file: string, avoids: readonly string[]): boolean { + return avoids.some((avoid) => { + const normalized = avoid + .trim() + .replace(/^(?:\.\.?\/)+/, '') + .replace(/\/+$/, ''); + if (!normalized) return false; + if (normalized.includes('*') || normalized.includes('?')) return globExpression(normalized).test(file); + return file === normalized || file.startsWith(`${normalized}/`) || file.includes(`/${normalized}/`); + }); +} diff --git a/apps/worker/src/ai/sast/capella/prompt-context.ts b/apps/worker/src/ai/sast/capella/prompt-context.ts new file mode 100644 index 00000000..10889070 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/prompt-context.ts @@ -0,0 +1,171 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { stableJson } from './artifacts.js'; +import type { CapellaFinding } from './finding-types.js'; +import type { Investigation, KbResult } from './schemas.js'; + +function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +export interface CapellaToolContext { + readonly [key: string]: string; + readonly CAPELLA_EXTRA_TOOLS: string; + readonly CAPELLA_RECORDING_ROUTE: string; +} + +const STRUCTURED_OUTPUT: CapellaToolContext = { + CAPELLA_EXTRA_TOOLS: '', + CAPELLA_RECORDING_ROUTE: 'by returning it as your structured output', +}; + +export const ARCHITECTURE_TOOLS = STRUCTURED_OUTPUT; +export const THREAT_MODEL_TOOLS = STRUCTURED_OUTPUT; +export const PLAN_TOOLS = STRUCTURED_OUTPUT; +export const TRIAGE_TOOLS = STRUCTURED_OUTPUT; +export const RESEARCH_TOOLS: CapellaToolContext = { + CAPELLA_EXTRA_TOOLS: ', plus `report_finding`', + CAPELLA_RECORDING_ROUTE: 'by calling `report_finding`', +}; +export const DEDUPE_TOOLS: CapellaToolContext = { + CAPELLA_EXTRA_TOOLS: ', plus `record_duplicates`', + CAPELLA_RECORDING_ROUTE: 'by calling `record_duplicates`', +}; +export const REVIEW_TOOLS: CapellaToolContext = { + CAPELLA_EXTRA_TOOLS: ', plus `record_review_verdict`', + CAPELLA_RECORDING_ROUTE: 'by calling `record_review_verdict`', +}; +export const CRITIC_TOOLS: CapellaToolContext = { + CAPELLA_EXTRA_TOOLS: ', plus `record_viability`', + CAPELLA_RECORDING_ROUTE: 'by calling `record_viability`', +}; +export const CONFIRM_TOOLS: CapellaToolContext = { + CAPELLA_EXTRA_TOOLS: ', plus `record_static_confirmation`', + CAPELLA_RECORDING_ROUTE: 'by calling `record_static_confirmation`', +}; +export const CALIBRATE_TOOLS: CapellaToolContext = { + CAPELLA_EXTRA_TOOLS: ', plus `record_calibration`', + CAPELLA_RECORDING_ROUTE: 'by calling `record_calibration`', +}; + +/** + * The context keys each prompt template requires. The loader refuses to render + * a prompt whose caller omitted one, so a template edit that adds a placeholder + * must extend this table or every render of that stage fails fast. + */ +export const CAPELLA_PROMPT_CONTEXT_KEYS = { + 'sast.capella.architecture': [ + 'CAPELLA_EXTRA_TOOLS', + 'CAPELLA_RECORDING_ROUTE', + 'LANGUAGE_CONTEXT', + 'BOUNDARY_CONTEXT', + ], + 'sast.capella.threat_model': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE', 'KB_DIR'], + 'sast.capella.plan': [ + 'CAPELLA_EXTRA_TOOLS', + 'CAPELLA_RECORDING_ROUTE', + 'KB_DIR', + 'LANGUAGE_CONTEXT', + 'BOUNDARY_CONTEXT', + ], + 'sast.capella.triage': [ + 'CAPELLA_EXTRA_TOOLS', + 'CAPELLA_RECORDING_ROUTE', + 'LANGUAGE_CONTEXT', + 'BOUNDARY_CONTEXT', + 'TARGET_FILES', + ], + 'sast.capella.research': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE', 'LANGUAGE_CONTEXT', 'BOUNDARY_CONTEXT'], + 'sast.capella.dedupe': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE'], + 'sast.capella.review': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE'], + 'sast.capella.critic': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE', 'KB_DIR'], + 'sast.capella.confirm': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE'], + 'sast.capella.calibrate': ['CAPELLA_EXTRA_TOOLS', 'CAPELLA_RECORDING_ROUTE', 'KB_DIR'], +} as const; + +function sortedUnique(values: readonly string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort(); +} + +/** Prompt-only steering. Tool enforcement remains authoritative for denied paths. */ +export function buildCodePathScopeSnippet(focus: readonly string[], avoids: readonly string[]): string { + const normalizedFocus = sortedUnique(focus); + const normalizedAvoids = sortedUnique(avoids); + if (normalizedFocus.length === 0 && normalizedAvoids.length === 0) return ''; + + const lines = ['### Repository code-path scope']; + if (normalizedFocus.length > 0) { + lines.push( + '', + 'Prioritize these configured paths while preserving end-to-end traces:', + ...normalizedFocus.map((path) => `- ${path}`), + ); + } + if (normalizedAvoids.length > 0) { + lines.push( + '', + 'Do not inspect or report findings whose sink is under these excluded paths:', + ...normalizedAvoids.map((path) => `- ${path}`), + ); + } + return lines.join('\n'); +} + +/** Embed the KB because repository-confined tools cannot read the sibling artifact root. */ +export function buildKnowledgeBaseContext(knowledgeBase: KbResult): string { + return [ + '## Shannon host-provided knowledge base', + '', + 'The knowledge base is supplied as structured data below. Do not look for it in the repository.', + '', + '', + stableJson(knowledgeBase).trimEnd(), + '', + ].join('\n'); +} + +/** Embed the immutable current finding set for verdict stages. */ +export function buildFindingsContext(findings: readonly CapellaFinding[]): string { + const ordered = [...findings].sort((left, right) => compareText(left.id, right.id)); + return [ + '## Shannon host-provided findings', + '', + 'The complete current finding set is supplied below. Do not look for a `findings/` directory.', + 'Use repository tools only for the source paths cited by these records.', + '', + '', + stableJson(ordered).trimEnd(), + '', + ].join('\n'); +} + +export function buildResearchAssignment( + investigation: Investigation, + flaggedFiles: readonly string[], + knowledgeBase: KbResult, +): string { + const referenced = new Set(investigation.kb_references ?? []); + const kbEntries = [...knowledgeBase.entities, ...knowledgeBase.vulnerabilities] + .filter((entry) => referenced.size === 0 || [...referenced].some((ref) => ref.includes(entry.name))) + .sort((left, right) => compareText(left.name, right.name)); + return [ + '## Your assignment', + '', + `Question: ${investigation.question}`, + '', + 'Flagged target files:', + ...[...flaggedFiles].sort().map((file) => `- ${file}`), + '', + 'The referenced KB entries are embedded below. Use this content directly; do not look for KB Markdown files in the repository.', + '', + '', + stableJson(kbEntries).trimEnd(), + '', + ].join('\n'); +} diff --git a/apps/worker/src/ai/sast/capella/prompt-loader.ts b/apps/worker/src/ai/sast/capella/prompt-loader.ts new file mode 100644 index 00000000..74671ca5 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/prompt-loader.ts @@ -0,0 +1,113 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { readdirSync, readFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import Handlebars from 'handlebars'; +import { CapellaRetryableError, ConfigurationError } from './errors.js'; +import { CAPELLA_PROMPT_CONTEXT_KEYS } from './prompt-context.js'; + +/** Transient I/O only; a missing or malformed asset is configuration, not retryable. */ +const RETRYABLE_PROMPT_IO_CODES = new Set(['EAGAIN', 'EBUSY', 'EIO', 'EMFILE', 'ENFILE', 'ENOMEM']); + +export const CAPELLA_PROMPT_IDS = [ + 'sast.capella.architecture', + 'sast.capella.threat_model', + 'sast.capella.plan', + 'sast.capella.triage', + 'sast.capella.research', + 'sast.capella.dedupe', + 'sast.capella.review', + 'sast.capella.critic', + 'sast.capella.confirm', + 'sast.capella.calibrate', +] as const; + +export type CapellaPromptId = (typeof CAPELLA_PROMPT_IDS)[number]; + +export interface RenderCapellaPromptOptions { + readonly pipelineTestingMode?: boolean; +} + +export interface CapellaPromptLoader { + render( + promptId: CapellaPromptId, + context?: Readonly>, + options?: RenderCapellaPromptOptions, + ): string; +} + +/** Create an isolated loader that registers only Capella-owned partials. */ +export function createCapellaPromptLoader(promptRoot: string): CapellaPromptLoader { + const handlebars = Handlebars.create(); + const partialsDir = join(promptRoot, 'partials'); + const cache = new Map(); + + let partialFiles: string[]; + try { + partialFiles = readdirSync(partialsDir) + .filter((file) => /^capella-[A-Za-z0-9._-]+\.hbs$/.test(file)) + .sort(); + } catch (error) { + throw promptReadError(error, 'PROMPT_PARTIALS_UNAVAILABLE'); + } + + for (const file of partialFiles) { + const name = basename(file, '.hbs'); + handlebars.registerPartial(name, readPromptFile(join(partialsDir, file))); + } + + return { + render( + promptId: CapellaPromptId, + context: Readonly> = {}, + options: RenderCapellaPromptOptions = {}, + ): string { + if (!(CAPELLA_PROMPT_IDS as readonly string[]).includes(promptId)) { + throw new ConfigurationError('Unknown Capella prompt id'); + } + for (const requiredKey of CAPELLA_PROMPT_CONTEXT_KEYS[promptId]) { + if (!(requiredKey in context)) { + throw new ConfigurationError(`Capella prompt context is missing ${requiredKey}`); + } + } + const relative = promptId.replace(/\./g, '/'); + const suffix = options.pipelineTestingMode === true ? '.test.hbs' : '.prompt.hbs'; + const filePath = join(promptRoot, `${relative}${suffix}`); + let template = cache.get(filePath); + if (!template) { + try { + template = handlebars.compile(readPromptFile(filePath), { noEscape: true }); + } catch (error) { + if (error instanceof ConfigurationError || error instanceof CapellaRetryableError) throw error; + throw new ConfigurationError('Required Capella prompt asset is invalid', 'PROMPT_COMPILE_FAILED'); + } + cache.set(filePath, template); + } + try { + return template(context); + } catch { + throw new ConfigurationError('Required Capella prompt asset is invalid', 'PROMPT_RENDER_FAILED'); + } + }, + }; +} + +function readPromptFile(filePath: string): string { + try { + return readFileSync(filePath, 'utf8'); + } catch (error) { + throw promptReadError(error, 'PROMPT_ASSET_UNAVAILABLE'); + } +} + +function promptReadError(error: unknown, unavailableCode: string): Error { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code && RETRYABLE_PROMPT_IO_CODES.has(code)) { + return new CapellaRetryableError('Capella prompt assets could not be read', 'PROMPT_IO'); + } + return new ConfigurationError('Required Capella prompt asset is unavailable', unavailableCode); +} diff --git a/apps/worker/src/ai/sast/capella/report.ts b/apps/worker/src/ai/sast/capella/report.ts new file mode 100644 index 00000000..a8db3b16 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/report.ts @@ -0,0 +1,94 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * Deterministic `report.md` rendering. There is no model call: the report is + * built from the same structured records the SARIF is, keeping the LLM out of + * the JSON-to-Markdown conversion. + * + * Unlike the SARIF, the report renders *every* finding, including those the + * export gate drops, and surfaces the report-only calibration (`mantis_risk_score`, + * `sanity_triage_applied`) so an operator can see what calibrate would have said. + */ + +import type { CapellaFinding } from './finding-types.js'; + +function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function riskLine(finding: CapellaFinding): string { + if (finding.mantis_risk_score === undefined) return ''; + const caps = finding.sanity_triage_applied ? ` — caps: ${finding.sanity_triage_applied}` : ''; + const priority = finding.priority ? ` (${finding.priority})` : ''; + return `\n- **Calibrated risk:** ${finding.mantis_risk_score}/10${priority}${caps}`; +} + +function renderFinding(finding: CapellaFinding): string { + const location = finding.code_paths[0] ?? '(no location)'; // sink = primary location + const viability = finding.production_viability ? ` · ${finding.production_viability}` : ''; + return [ + `### ${finding.title}`, + '', + `- **CWE:** ${finding.cwe}`, + `- **Severity:** ${finding.severity}`, + `- **Status:** ${finding.status}${viability}`, + `- **Location:** \`${location}\``, + `- **Code path:** ${[...finding.code_paths] + .reverse() + .map((p) => `\`${p}\``) + .join(' → ')}`, + riskLine(finding), + '', + finding.description, + '', + `**Impact:** ${finding.impact}`, + '', + `**Mitigation:** ${finding.mitigation}`, + finding.reasoning ? `\n**Reviewer reasoning:** ${finding.reasoning}` : '', + ] + .filter((line) => line !== '') + .join('\n'); +} + +/** Render the full report from every finding, partitioned by actual SARIF membership. */ +export function renderCapellaReport( + findings: readonly CapellaFinding[], + exportedFindingIds: ReadonlySet, + repoPath: string, +): string { + const ordered = [...findings].sort((left, right) => compareText(left.id, right.id)); + const exported = ordered.filter((finding) => exportedFindingIds.has(finding.id)); + const dropped = ordered.filter((finding) => !exportedFindingIds.has(finding.id)); + + const sections: string[] = [ + '# Capella SAST Report', + '', + `Repository: \`${repoPath}\``, + '', + `- Exported to SARIF: **${exported.length}**`, + `- Not exported to SARIF: **${dropped.length}**`, + '', + '## Exported findings', + '', + exported.length ? exported.map(renderFinding).join('\n\n---\n\n') : '_None._', + ]; + + if (dropped.length) { + sections.push( + '', + '## Not exported', + '', + 'These were filtered before SARIF export by status, viability, or code-path rules. Shown for context.', + '', + dropped.map(renderFinding).join('\n\n---\n\n'), + ); + } + + return `${sections.join('\n')}\n`; +} diff --git a/apps/worker/src/ai/sast/capella/safe-failures.ts b/apps/worker/src/ai/sast/capella/safe-failures.ts new file mode 100644 index 00000000..eff122c8 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/safe-failures.ts @@ -0,0 +1,59 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** Workflow-safe public failure projection for Agentic SAST. */ + +export const CAPELLA_SAFE_FAILURE_MESSAGES = Object.freeze({ + AuthenticationError: 'Provider authentication failed. Verify the configured credential.', + ConfigurationError: 'Agentic SAST configuration is invalid.', + InvalidInputError: 'Agentic SAST received invalid input.', + SastContractError: 'An agentic SAST step returned an unusable result.', + AgentExecutionError: 'An agentic SAST step failed.', +} as const); + +export type CapellaSafeFailureType = keyof typeof CAPELLA_SAFE_FAILURE_MESSAGES; + +const CAPELLA_TERMINAL_STAGE_LABELS = Object.freeze({ + architecture: 'architecture', + 'threat-model': 'threat model', + plan: 'planning', + research: 'audit wave', + dedupe: 'deduplication', + review: 'review', + critic: 'critic', + confirm: 'confirmation', + calibrate: 'calibration', + export: 'export', + workflow: 'orchestration', +} as const); + +export function capellaTerminalStageLabel(stage: keyof typeof CAPELLA_TERMINAL_STAGE_LABELS): string { + return CAPELLA_TERMINAL_STAGE_LABELS[stage]; +} + +export function isCapellaTerminalStageLabel(value: string): boolean { + return (Object.values(CAPELLA_TERMINAL_STAGE_LABELS) as readonly string[]).includes(value); +} + +export function capellaSafeFailureMessage(type: string | null | undefined): string { + if (type !== undefined && type !== null && type in CAPELLA_SAFE_FAILURE_MESSAGES) { + return CAPELLA_SAFE_FAILURE_MESSAGES[type as CapellaSafeFailureType]; + } + return CAPELLA_SAFE_FAILURE_MESSAGES.AgentExecutionError; +} + +// The two literal strings below are not produced by this module: they are emitted by the parent +// pentest pipeline when Capella never got far enough to fail its own way (a scan cancelled before +// the child workflow started, or infrastructure that failed before any stage ran). Listing them +// here keeps this predicate the single place that recognizes every message the workflow-safe +// surface is allowed to show, not just this file's own table. +export function isCapellaSafeFailureMessage(message: string): boolean { + return ( + (Object.values(CAPELLA_SAFE_FAILURE_MESSAGES) as readonly string[]).includes(message) || + message === 'Agentic SAST infrastructure failed before producing a usable result.' || + message === 'Agentic SAST had not finished when the scan stopped.' + ); +} diff --git a/apps/worker/src/ai/sast/capella/sarif-exporter.ts b/apps/worker/src/ai/sast/capella/sarif-exporter.ts new file mode 100644 index 00000000..d16c76d3 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/sarif-exporter.ts @@ -0,0 +1,298 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { + CAPELLA_SARIF_DRIVER_NAME, + CAPELLA_SARIF_DRIVER_VERSION, + CAPELLA_SARIF_INFORMATION_URI, + CAPELLA_SARIF_SCHEMA, + type CapellaSarif, + type CapellaSarifLevel, + type CapellaSarifResult, + type CapellaSarifRule, + type CapellaSarifSeverity, + validateCapellaSarif, +} from '../sarif-profile.js'; +import type { SarifRef } from '../types.js'; +import { atomicPublishBytes, sha256Bytes, stableJson } from './artifacts.js'; +import { SastContractError } from './errors.js'; +import type { CapellaFinding, CapellaSeverity } from './finding-types.js'; +import { isExcludedCodePath, isNormalizedRepositoryPath, parseCodePath } from './paths.js'; +import { renderCapellaReport } from './report.js'; +import type { AtomicPublishOptions } from './types.js'; +import { isCapellaFinding } from './validation.js'; + +export interface CapellaExportResult { + readonly sarif: SarifRef; + readonly findingCount: number; + readonly coverage: 'complete' | 'reduced'; + readonly warnings: string[]; + readonly reportPath: string; +} + +export interface CapellaExportOptions { + readonly artifactRoot: string; + readonly repositoryLabel: string; + readonly codePathAvoids: readonly string[]; + readonly publishOptions?: AtomicPublishOptions; + readonly cancellationSignal?: AbortSignal; +} + +function throwIfExportCancelled(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + throw new DOMException('Capella export cancelled.', 'AbortError'); +} + +export function passesExportGate(finding: CapellaFinding): boolean { + return ( + finding.status === 'VALID' && + (finding.production_viability === 'VIABLE' || finding.production_viability === 'CONDITIONAL_VIABLE') + ); +} + +function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function severityName(severity: CapellaSeverity): CapellaSarifSeverity { + switch (severity) { + case 'CRITICAL': + return 'Critical'; + case 'HIGH': + return 'High'; + case 'MEDIUM': + return 'Medium'; + case 'LOW': + return 'Low'; + } +} + +function severityLevel(severity: CapellaSeverity): CapellaSarifLevel { + if (severity === 'CRITICAL' || severity === 'HIGH') return 'error'; + if (severity === 'MEDIUM') return 'warning'; + return 'note'; +} + +function cweHelpUri(cwe: string): string { + return `https://cwe.mitre.org/data/definitions/${cwe.slice('CWE-'.length)}.html`; +} + +function threadLocationLabel(index: number, locationCount: number): 'Source' | 'Step' | 'Sink' { + if (index === locationCount - 1) return 'Sink'; + if (index === 0) return 'Source'; + return 'Step'; +} + +function isExportableFinding(value: unknown): value is CapellaFinding { + if (!isCapellaFinding(value)) return false; + if (value.code_paths.length === 0) return false; + return value.code_paths.every((entry) => { + const parsed = parseCodePath(entry); + return parsed !== undefined && isNormalizedRepositoryPath(parsed.file); + }); +} + +function buildRule(finding: CapellaFinding): CapellaSarifRule { + const cwe = finding.cwe as `CWE-${number}`; + return { + id: cwe, + name: finding.title, + shortDescription: { text: `${finding.cwe}: ${finding.title}` }, + fullDescription: { text: finding.description }, + helpUri: cweHelpUri(finding.cwe), + properties: { cwe, tags: ['security', 'vulnerability'] }, + }; +} + +function buildResult(finding: CapellaFinding): CapellaSarifResult { + const primary = parseCodePath(finding.code_paths[0] ?? ''); + if (!primary || !isNormalizedRepositoryPath(primary.file)) { + throw new SastContractError('Capella export received an invalid primary finding location', 'SARIF_LOCATION'); + } + const parsedTrace = finding.code_paths + .map(parseCodePath) + .filter((step): step is NonNullable> => { + return step !== undefined && isNormalizedRepositoryPath(step.file); + }); + const sourceToSink = [...parsedTrace].reverse(); + const threadLocations = sourceToSink.map((step, index) => ({ + location: { + physicalLocation: { + artifactLocation: { uri: step.file, uriBaseId: '%SRCROOT%' as const }, + region: { startLine: step.line }, + }, + message: { text: threadLocationLabel(index, sourceToSink.length) }, + }, + importance: index === sourceToSink.length - 1 ? ('essential' as const) : ('important' as const), + })); + const severity = severityName(finding.severity); + const cwe = finding.cwe as `CWE-${number}`; + return { + ruleId: cwe, + level: severityLevel(finding.severity), + message: { text: finding.title }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: primary.file, uriBaseId: '%SRCROOT%' }, + region: { startLine: primary.line }, + }, + }, + ], + codeFlows: [{ threadFlows: [{ locations: threadLocations }] }], + properties: { + severity, + cwe, + status: 'verified', + description: finding.impact ? `${finding.description}\n\nImpact: ${finding.impact}` : finding.description, + findingSubType: 'AGENT_SAST', + }, + }; +} + +export function buildCapellaSarif(findings: readonly CapellaFinding[], repositoryLabel: string): CapellaSarif { + const ordered = [...findings].sort((left, right) => compareText(left.id, right.id)); + const rulesById = new Map(); + for (const finding of ordered) { + if (!rulesById.has(finding.cwe)) rulesById.set(finding.cwe, buildRule(finding)); + } + const rules = [...rulesById.values()].sort((left, right) => compareText(left.id, right.id)); + const results = ordered.map(buildResult); + return { + $schema: CAPELLA_SARIF_SCHEMA, + version: '2.1.0', + runs: [ + { + tool: { + driver: { + name: CAPELLA_SARIF_DRIVER_NAME, + version: CAPELLA_SARIF_DRIVER_VERSION, + informationUri: CAPELLA_SARIF_INFORMATION_URI, + rules, + }, + }, + results, + properties: { repository: repositoryLabel, totalFindings: results.length }, + }, + ], + }; +} + +async function readSuccessfulSarifRef(artifactRoot: string, expectedPath: string): Promise { + try { + const run = JSON.parse(await readFile(resolve(artifactRoot, 'run.json'), 'utf8')) as Record; + if (run.finalState !== 'succeeded') return undefined; + if (!run.sarif || typeof run.sarif !== 'object') { + throw new SastContractError('A successful Capella run is missing its SARIF reference', 'SARIF_REFERENCE'); + } + const sarif = run.sarif as Record; + if (sarif.path !== expectedPath || typeof sarif.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(sarif.sha256)) { + throw new SastContractError('A successful Capella run contains an invalid SARIF reference', 'SARIF_REFERENCE'); + } + return { path: sarif.path, sha256: sarif.sha256 }; + } catch (error) { + if (error instanceof SastContractError) throw error; + return undefined; + } +} + +/** Revalidate, filter, report, serialize once, and atomically publish exact SARIF bytes. */ +export async function exportCapellaFindings( + rawFindings: readonly unknown[], + options: CapellaExportOptions, +): Promise { + throwIfExportCancelled(options.cancellationSignal); + + const warnings: string[] = []; + const validFindings = rawFindings.filter(isExportableFinding); + const invalidCount = rawFindings.length - validFindings.length; + if (invalidCount > 0) warnings.push(`${invalidCount} invalid finding(s) were excluded`); + + const gated = validFindings.filter(passesExportGate); + const exported = gated + .filter((finding) => { + const primary = parseCodePath(finding.code_paths[0] ?? ''); + return primary !== undefined && !isExcludedCodePath(primary.file, options.codePathAvoids); + }) + .sort((left, right) => compareText(left.id, right.id)); + const excludedCount = gated.length - exported.length; + if (excludedCount > 0) warnings.push(`${excludedCount} finding(s) matched code-path exclusions`); + if (validFindings.length > 0 && exported.length === 0) { + warnings.push(`all ${validFindings.length} valid finding record(s) were dropped before export`); + } + + const sarifDocument = buildCapellaSarif(exported, options.repositoryLabel); + const validation = validateCapellaSarif(sarifDocument); + if (!validation.valid) { + throw new SastContractError('Capella SARIF document validation failed', 'SARIF_VALIDATION'); + } + if (sarifDocument.runs[0].properties.totalFindings !== exported.length) { + throw new SastContractError('Capella SARIF document count does not match exported findings', 'SARIF_COUNT'); + } + + const sarifBytes = stableJson(sarifDocument); + const digest = sha256Bytes(sarifBytes); + const reportPath = resolve(options.artifactRoot, 'report.md'); + const sarifPath = resolve(options.artifactRoot, 'capella.sarif'); + + const successful = await readSuccessfulSarifRef(options.artifactRoot, sarifPath); + if (successful) { + let existingBytes: Buffer; + try { + existingBytes = await readFile(successful.path); + await readFile(reportPath); + } catch { + throw new SastContractError('A successful Capella export is no longer complete and readable', 'SARIF_READ'); + } + if ( + successful.path !== sarifPath || + successful.sha256 !== sha256Bytes(existingBytes) || + successful.sha256 !== digest + ) { + throw new SastContractError( + 'A successful Capella SARIF reference is immutable and no longer matches export bytes', + 'SARIF_IMMUTABLE', + ); + } + return { + sarif: successful, + findingCount: exported.length, + coverage: invalidCount > 0 ? 'reduced' : 'complete', + warnings: [...warnings].sort(), + reportPath, + }; + } + + throwIfExportCancelled(options.cancellationSignal); + await atomicPublishBytes( + options.artifactRoot, + reportPath, + renderCapellaReport(validFindings, new Set(exported.map((finding) => finding.id)), options.repositoryLabel), + ); + throwIfExportCancelled(options.cancellationSignal); + const publishedDigest = await atomicPublishBytes( + options.artifactRoot, + sarifPath, + sarifBytes, + options.publishOptions ?? {}, + ); + if (publishedDigest !== digest) { + throw new SastContractError('Published Capella SARIF bytes changed before hashing', 'SARIF_DIGEST'); + } + + return { + sarif: { path: sarifPath, sha256: publishedDigest }, + findingCount: exported.length, + coverage: invalidCount > 0 ? 'reduced' : 'complete', + warnings: [...warnings].sort(), + reportPath, + }; +} diff --git a/apps/worker/src/ai/sast/capella/schemas.ts b/apps/worker/src/ai/sast/capella/schemas.ts new file mode 100644 index 00000000..06e2eb25 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/schemas.ts @@ -0,0 +1,159 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * Structured-output schemas for the Capella stages that return a document rather + * than mutate a finding through a collector tool. + * + * Architecture, threat-model, plan and the triage wave each produce one document + * describing their whole phase, so a schema is the right shape; the harness + * writes the file from the returned object. Everything a *finding* passes through + * goes via a collector tool instead (validation there happens while the agent can + * still fix it, which a schema violation at the end of a run cannot). + */ + +// === Architecture (Knowledge Base) === + +export interface KbEntity { + /** Filename stem under `kb/entities/` or `kb/vulnerabilities/`, e.g. `auth_module` or `CWE-89`. */ + name: string; + content: string; +} + +export interface KbResult { + architecture: string; + entities: KbEntity[]; + vulnerabilities: KbEntity[]; + index: string; + dependencies: Record; +} + +const KB_ENTITY = { + type: 'object', + properties: { + name: { type: 'string', description: 'Filename stem, e.g. "auth_module" or "CWE-89" (no extension, no path)' }, + content: { type: 'string', description: 'The Markdown body of the file' }, + }, + required: ['name', 'content'], + additionalProperties: false, +} as const; + +export const ARCHITECTURE_SCHEMA = { + type: 'object', + properties: { + architecture: { + type: 'string', + description: 'The architecture.md body: data flows, zones, availability requirements', + }, + entities: { type: 'array', items: KB_ENTITY, description: 'One entry per component (kb/entities/.md)' }, + vulnerabilities: { + type: 'array', + items: KB_ENTITY, + description: 'One entry per bug class (kb/vulnerabilities/.md)', + }, + index: { type: 'string', description: 'The index.md body: a catalog linking every entity and vulnerability file' }, + dependencies: { + type: 'object', + description: 'Import/dependency edges: keys are source files, values are the files that import them. {} if none.', + additionalProperties: { type: 'array', items: { type: 'string' } }, + }, + }, + required: ['architecture', 'entities', 'vulnerabilities', 'index', 'dependencies'], + additionalProperties: false, +} as const satisfies Record; + +// === Threat Model === + +export interface ThreatModelResult { + threatModel: string; + intent: 'PRODUCTION' | 'SAMPLE_OR_TEST_ONLY'; +} + +export const THREAT_MODEL_SCHEMA = { + type: 'object', + properties: { + threatModel: { type: 'string', description: 'The full THREAT_MODEL.md body, including the Deployment Intent line' }, + intent: { + type: 'string', + enum: ['PRODUCTION', 'SAMPLE_OR_TEST_ONLY'], + description: 'The deployment-intent verdict — exactly one of these two values', + }, + }, + required: ['threatModel', 'intent'], + additionalProperties: false, +} as const satisfies Record; + +// === Plan === + +export interface Investigation { + title: string; + target_files: string[]; + kb_references: string[]; + question: string; +} + +export interface PlanResult { + investigations: Investigation[]; +} + +export const PLAN_SCHEMA = { + type: 'object', + properties: { + investigations: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + target_files: { type: 'array', items: { type: 'string' }, description: 'Repository-relative files to audit' }, + kb_references: { + type: 'array', + items: { type: 'string' }, + description: 'KB files providing context, e.g. entities/auth.md', + }, + question: { type: 'string', description: 'The reviewing prompt for the researcher' }, + }, + required: ['title', 'target_files', 'kb_references', 'question'], + additionalProperties: false, + }, + }, + }, + required: ['investigations'], + additionalProperties: false, +} as const satisfies Record; + +// === Triage (research wave 1) === + +export interface TriageClassification { + file: string; + potentially_flawed: boolean; + reason: string; +} + +export interface TriageResult { + classifications: TriageClassification[]; +} + +export const TRIAGE_SCHEMA = { + type: 'object', + properties: { + classifications: { + type: 'array', + items: { + type: 'object', + properties: { + file: { type: 'string' }, + potentially_flawed: { type: 'boolean' }, + reason: { type: 'string' }, + }, + required: ['file', 'potentially_flawed', 'reason'], + additionalProperties: false, + }, + }, + }, + required: ['classifications'], + additionalProperties: false, +} as const satisfies Record; diff --git a/apps/worker/src/ai/sast/capella/stages/architecture.ts b/apps/worker/src/ai/sast/capella/stages/architecture.ts new file mode 100644 index 00000000..c62917a0 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/stages/architecture.ts @@ -0,0 +1,380 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Type } from 'typebox'; +import { loadArtifactRef, sha256Bytes, stableJson } from '../artifacts.js'; +import { CapellaRetryableError } from '../errors.js'; +import { + ARCHITECTURE_TOOLS, + buildCodePathScopeSnippet, + buildKnowledgeBaseContext, + PLAN_TOOLS, + THREAT_MODEL_TOOLS, +} from '../prompt-context.js'; +import { createCapellaPromptLoader } from '../prompt-loader.js'; +import { + ARCHITECTURE_SCHEMA, + type KbEntity, + type KbResult, + PLAN_SCHEMA, + THREAT_MODEL_SCHEMA, + type ThreatModelResult, +} from '../schemas.js'; +import type { + ArchitectureValue, + CapellaStageInput, + CapellaStageRuntime, + CompletedStage, + PlanStageInput, + PlanValue, + ThreatModelStageInput, + ThreatModelValue, +} from '../types.js'; +import { + isArchitectureValue, + isPlanValue, + isThreatModelResult, + isThreatModelValue, + salvageKbResult, + salvagePlanResult, +} from '../validation.js'; +import { + artifactLineage, + buildStageFingerprint, + completeStage, + maybeReuseStage, + publishTextAsset, + resolveStageIdentity, +} from './shared.js'; + +// Per-session caps on model turns, sized to how much repository exploration each +// stage legitimately needs before its structured output is due. +const ARCHITECTURE_MAX_TURNS = 400; +const THREAT_MODEL_MAX_TURNS = 150; +const PLAN_MAX_TURNS = 150; +const KB_CONTENT_HASH_LENGTH = 12; + +function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function slugName(name: string): string { + return ( + name + .replace(/\.md$/i, '') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-|-$/g, '') || 'entity' + ); +} + +async function publishKnowledgeBase(input: CapellaStageInput, knowledgeBase: KbResult): Promise { + await publishTextAsset(input, 'kb/architecture.md', knowledgeBase.architecture); + await publishTextAsset(input, 'kb/index.md', knowledgeBase.index); + await publishTextAsset(input, 'kb/dependencies.json', stableJson(knowledgeBase.dependencies)); + + async function publishEntities(subdirectory: string, entities: readonly KbEntity[]): Promise { + for (const asset of knowledgeBaseEntityAssets(subdirectory, entities).assets) { + await publishTextAsset(input, asset.relativePath, asset.entity.content); + } + } + + await publishEntities('entities', knowledgeBase.entities); + await publishEntities('vulnerabilities', knowledgeBase.vulnerabilities); +} + +interface KnowledgeBaseEntityAsset { + readonly entity: KbEntity; + readonly relativePath: string; +} + +interface KnowledgeBaseEntityAssets { + readonly assets: KnowledgeBaseEntityAsset[]; + readonly uniqueEntities: KbEntity[]; + readonly duplicateCount: number; +} + +/** Deterministic collision-safe KB names, scoped independently to each subdirectory. */ +export function knowledgeBaseEntityAssets( + subdirectory: string, + entities: readonly KbEntity[], +): KnowledgeBaseEntityAssets { + const groups = new Map>(); + for (const entity of entities) { + const baseSlug = slugName(entity.name); + const group = groups.get(baseSlug) ?? []; + group.push({ entity, contentHash: sha256Bytes(entity.content) }); + groups.set(baseSlug, group); + } + + const assets: KnowledgeBaseEntityAsset[] = []; + const uniqueEntities: KbEntity[] = []; + let duplicateCount = 0; + for (const baseSlug of [...groups.keys()].sort(compareText)) { + const group = groups.get(baseSlug) ?? []; + group.sort( + (left, right) => + compareText(left.entity.name, right.entity.name) || + compareText(left.contentHash, right.contentHash) || + compareText(left.entity.content, right.entity.content), + ); + const unique = group.filter((entry, index) => { + const previous = group[index - 1]; + const duplicate = + previous !== undefined && + previous.entity.name === entry.entity.name && + previous.entity.content === entry.entity.content; + if (duplicate) duplicateCount += 1; + return !duplicate; + }); + const suffixCounts = new Map(); + for (const entry of unique) { + const shortHash = entry.contentHash.slice(0, KB_CONTENT_HASH_LENGTH); + suffixCounts.set(shortHash, (suffixCounts.get(shortHash) ?? 0) + 1); + } + const suffixOrdinals = new Map(); + for (const entry of unique) { + const shortHash = entry.contentHash.slice(0, KB_CONTENT_HASH_LENGTH); + let filename = baseSlug; + if (unique.length > 1) { + filename = `${baseSlug}-${shortHash}`; + // A truncated hash prefix can still collide between two genuinely distinct contents; an + // ordinal suffix breaks that tie instead of one entity silently overwriting the other's file. + if ((suffixCounts.get(shortHash) ?? 0) > 1) { + const ordinal = (suffixOrdinals.get(shortHash) ?? 0) + 1; + suffixOrdinals.set(shortHash, ordinal); + filename = `${filename}-${String(ordinal)}`; + } + } + uniqueEntities.push(entry.entity); + assets.push({ entity: entry.entity, relativePath: `kb/${subdirectory}/${filename}.md` }); + } + } + return { assets, uniqueEntities, duplicateCount }; +} + +export async function runArchitectureStage( + input: CapellaStageInput, + runtime: CapellaStageRuntime, +): Promise> { + const startedAt = Date.now(); + const identity = await resolveStageIdentity(input); + const loader = createCapellaPromptLoader(input.promptDir); + const prompt = loader.render( + 'sast.capella.architecture', + { + ...ARCHITECTURE_TOOLS, + LANGUAGE_CONTEXT: '', + BOUNDARY_CONTEXT: buildCodePathScopeSnippet(input.codePathFocus, input.codePathAvoids), + }, + { pipelineTestingMode: input.pipelineTestingMode }, + ); + const fingerprint = buildStageFingerprint('architecture', identity, prompt, { + schema: ARCHITECTURE_SCHEMA, + }); + const reused = await maybeReuseStage(input, 'architecture', fingerprint, isArchitectureValue, identity, startedAt); + if (reused) return reused; + + const response = await runtime.executor.run({ + stage: 'architecture', + role: 'large', + cwd: input.repoPath, + systemPrompt: + 'You are the knowledge-base synthesizer of a security audit. Describe the security-relevant architecture ' + + 'of the codebase and return the complete knowledge base as structured output.', + userPrompt: prompt, + maxTurns: ARCHITECTURE_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: runtime.repositoryTools, + outputSchema: Type.Unsafe(ARCHITECTURE_SCHEMA), + signal: runtime.signal, + }); + const salvaged = salvageKbResult(response.output); + if (!salvaged) { + throw new CapellaRetryableError('Capella architecture output failed core validation', 'ARCHITECTURE_SCHEMA'); + } + + const entityAssets = knowledgeBaseEntityAssets('entities', salvaged.value.entities); + const vulnerabilityAssets = knowledgeBaseEntityAssets('vulnerabilities', salvaged.value.vulnerabilities); + const omittedEntityCount = + salvaged.omittedEntityCount + entityAssets.duplicateCount + vulnerabilityAssets.duplicateCount; + const knowledgeBase: KbResult = { + ...salvaged.value, + entities: entityAssets.uniqueEntities, + vulnerabilities: vulnerabilityAssets.uniqueEntities, + }; + const reduced = omittedEntityCount + salvaged.omittedDependencyCount > 0; + + const value: ArchitectureValue = { + knowledgeBase, + componentCount: knowledgeBase.entities.length, + ...(reduced && { + reduction: { + stage: 'architecture', + reason: 'invalid_architecture_items', + entityCount: salvaged.consideredEntityCount, + omittedEntityCount, + dependencyCount: salvaged.consideredDependencyCount, + omittedDependencyCount: salvaged.omittedDependencyCount, + }, + }), + }; + await publishKnowledgeBase(input, knowledgeBase); + return completeStage(input, 'architecture', fingerprint, response.usage, value, identity, startedAt); +} + +export async function runThreatModelStage( + input: ThreatModelStageInput, + runtime: CapellaStageRuntime, +): Promise> { + const startedAt = Date.now(); + const architecture = await loadArtifactRef( + input.artifactRoot, + input.architectureArtifact, + 'architecture', + isArchitectureValue, + ); + const identity = await resolveStageIdentity(input); + const loader = createCapellaPromptLoader(input.promptDir); + // The prompt template expects a knowledge-base directory; the KB is inlined below + // the prompt instead, so KB_DIR redirects the model to that inline context. + const prompt = `${loader.render( + 'sast.capella.threat_model', + { ...THREAT_MODEL_TOOLS, KB_DIR: 'the host-provided context below' }, + { pipelineTestingMode: input.pipelineTestingMode }, + )}\n\n${buildKnowledgeBaseContext(architecture.value.knowledgeBase)}`; + const fingerprint = buildStageFingerprint('threat-model', identity, prompt, { + architecture: artifactLineage(input.architectureArtifact), + schema: THREAT_MODEL_SCHEMA, + }); + const reused = await maybeReuseStage( + input, + 'threat-model', + fingerprint, + isThreatModelValue, + identity, + startedAt, + // Reuse is valid only while the published THREAT_MODEL.md still matches the + // artifact byte for byte; the fingerprint cannot see edits to the published asset. + async (value) => { + const expectedPath = resolve(input.artifactRoot, 'kb', 'THREAT_MODEL.md'); + if (value.threatModelPath !== expectedPath) return false; + try { + return (await readFile(expectedPath, 'utf8')) === value.threatModel; + } catch { + return false; + } + }, + ); + if (reused) return reused; + + const response = await runtime.executor.run({ + stage: 'threat-model', + role: 'medium', + cwd: input.repoPath, + systemPrompt: + 'You are the security architect of a security audit. Synthesize the threat model from the supplied knowledge ' + + 'base and return the deployment-intent verdict as its own field.', + userPrompt: prompt, + maxTurns: THREAT_MODEL_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: runtime.repositoryTools, + outputSchema: Type.Unsafe(THREAT_MODEL_SCHEMA), + signal: runtime.signal, + }); + if (!isThreatModelResult(response.output)) { + throw new CapellaRetryableError( + 'Capella threat-model output failed schema or intent validation', + 'THREAT_MODEL_SCHEMA', + ); + } + const threatModelPath = resolve(input.artifactRoot, 'kb', 'THREAT_MODEL.md'); + await publishTextAsset(input, 'kb/THREAT_MODEL.md', response.output.threatModel); + const value: ThreatModelValue = { ...response.output, threatModelPath }; + return completeStage(input, 'threat-model', fingerprint, response.usage, value, identity, startedAt); +} + +export async function runPlanStage( + input: PlanStageInput, + runtime: CapellaStageRuntime, +): Promise> { + const startedAt = Date.now(); + const architecture = await loadArtifactRef( + input.artifactRoot, + input.architectureArtifact, + 'architecture', + isArchitectureValue, + ); + const threatModel = await loadArtifactRef( + input.artifactRoot, + input.threatModelArtifact, + 'threat-model', + isThreatModelValue, + ); + const identity = await resolveStageIdentity(input); + const loader = createCapellaPromptLoader(input.promptDir); + const knowledgeBase = { + ...architecture.value.knowledgeBase, + threatModel: threatModel.value.threatModel, + intent: threatModel.value.intent, + }; + const prompt = `${loader.render( + 'sast.capella.plan', + { + ...PLAN_TOOLS, + KB_DIR: 'the host-provided context below', + LANGUAGE_CONTEXT: '', + BOUNDARY_CONTEXT: buildCodePathScopeSnippet(input.codePathFocus, input.codePathAvoids), + }, + { pipelineTestingMode: input.pipelineTestingMode }, + )}\n\n${buildKnowledgeBaseContext(architecture.value.knowledgeBase)}\n\n\n${threatModel.value.threatModel}\n`; + const fingerprint = buildStageFingerprint('plan', identity, prompt, { + architecture: artifactLineage(input.architectureArtifact), + threatModel: artifactLineage(input.threatModelArtifact), + schema: PLAN_SCHEMA, + knowledgeBaseDigest: stableJson(knowledgeBase), + }); + const reused = await maybeReuseStage(input, 'plan', fingerprint, isPlanValue, identity, startedAt); + if (reused) return reused; + + const response = await runtime.executor.run({ + stage: 'plan', + role: 'medium', + cwd: input.repoPath, + systemPrompt: + 'You are the strategist of a security audit. Produce an adaptive review roadmap that covers the production ' + + 'code and return it as structured output.', + userPrompt: prompt, + maxTurns: PLAN_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: runtime.repositoryTools, + outputSchema: Type.Unsafe(PLAN_SCHEMA), + signal: runtime.signal, + }); + const salvaged = salvagePlanResult(response.output); + if (!salvaged || salvaged.value.investigations.length === 0) { + throw new CapellaRetryableError('Capella plan output contained no usable investigations', 'PLAN_SCHEMA'); + } + const value: PlanValue = { + investigations: [...salvaged.value.investigations], + investigationCount: salvaged.value.investigations.length, + ...(salvaged.omittedCount > 0 && { + reduction: { + stage: 'plan', + reason: 'invalid_investigations', + consideredCount: salvaged.consideredCount, + usableCount: salvaged.value.investigations.length, + omittedCount: salvaged.omittedCount, + }, + }), + }; + await publishTextAsset(input, 'plan.json', stableJson(salvaged.value)); + return completeStage(input, 'plan', fingerprint, response.usage, value, identity, startedAt); +} diff --git a/apps/worker/src/ai/sast/capella/stages/export.ts b/apps/worker/src/ai/sast/capella/stages/export.ts new file mode 100644 index 00000000..ae03f30b --- /dev/null +++ b/apps/worker/src/ai/sast/capella/stages/export.ts @@ -0,0 +1,161 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type { AgenticSastReduction } from '../../types.js'; +import { + loadArtifactRef, + loadCompletedArtifact, + recordStageCompletion, + sha256Bytes, + stageArtifactPath, +} from '../artifacts.js'; +import { SastContractError } from '../errors.js'; +import { exportCapellaFindings } from '../sarif-exporter.js'; +import { type CompletedStage, type ExportStageInput, type ExportValue, ZERO_CAPELLA_USAGE } from '../types.js'; +import { isExportValue, isRawFindingSetValue } from '../validation.js'; +import { artifactLineage, buildStageFingerprint, completeStage, resolveStageIdentity } from './shared.js'; + +async function verifyPublishedExport(input: ExportStageInput, value: ExportValue): Promise { + const expectedSarifPath = resolve(input.artifactRoot, 'capella.sarif'); + const expectedReportPath = resolve(input.artifactRoot, 'report.md'); + if (value.sarif.path !== expectedSarifPath || value.reportPath !== expectedReportPath) { + throw new SastContractError('Cached Capella export references an unexpected path', 'SARIF_REFERENCE'); + } + + let bytes: Buffer; + try { + bytes = await readFile(expectedSarifPath); + await readFile(expectedReportPath); + } catch { + throw new SastContractError('Cached Capella export is incomplete', 'SARIF_READ'); + } + if (sha256Bytes(bytes) !== value.sarif.sha256) { + throw new SastContractError('Cached Capella SARIF digest mismatch', 'SARIF_DIGEST'); + } +} + +function assertExportSource(input: ExportStageInput): void { + const hasArtifact = input.findingsArtifact !== undefined; + const hasStage = input.findingsStage !== undefined; + if (hasArtifact !== hasStage) { + throw new SastContractError( + 'Capella export requires both a finding artifact and its source stage', + 'EXPORT_SOURCE', + ); + } + const hasFallbackReduction = input.fallbackReduction !== undefined; + const hasFallbackFailure = input.fallbackFailure !== undefined; + if (hasFallbackReduction !== hasFallbackFailure || input.fallbackReduction?.stage !== input.fallbackFailure?.stage) { + throw new SastContractError( + 'Capella fallback export requires one matching reduction and original failure', + 'EXPORT_FALLBACK', + ); + } +} + +function throwIfCancelled(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + throw new DOMException('Capella export cancelled.', 'AbortError'); +} + +function exportCompletionReductions( + value: ExportValue, + fallbackReduction: ExportStageInput['fallbackReduction'], +): readonly AgenticSastReduction[] { + const reductions: AgenticSastReduction[] = []; + if (value.reduction !== undefined) reductions.push(value.reduction); + reductions.push(...fallbackCompletionReductions(fallbackReduction)); + return reductions; +} + +function fallbackCompletionReductions( + fallbackReduction: ExportStageInput['fallbackReduction'], +): readonly AgenticSastReduction[] { + return fallbackReduction === undefined ? [] : [fallbackReduction]; +} + +/** Export a prior finding set, or a valid empty set for a workflow short circuit. */ +export async function runExportStage( + input: ExportStageInput, + cancellationSignal?: AbortSignal, +): Promise> { + const startedAt = Date.now(); + throwIfCancelled(cancellationSignal); + assertExportSource(input); + const identity = await resolveStageIdentity(input); + const sourceLineage = input.findingsArtifact ? artifactLineage(input.findingsArtifact) : null; + const fingerprint = buildStageFingerprint('export', identity, 'deterministic Capella SARIF export', { + sourceStage: input.findingsStage ?? null, + findings: sourceLineage, + repositoryLabel: input.repositoryLabel, + }); + + const completedPath = stageArtifactPath(input.artifactRoot, 'export'); + const cached = await loadCompletedArtifact(input.artifactRoot, completedPath, 'export', fingerprint, isExportValue); + if (cached) { + await verifyPublishedExport(input, cached.value); + throwIfCancelled(cancellationSignal); + await recordStageCompletion( + input, + identity.runInputFingerprint, + 'export', + cached.usage, + cached.value.warnings, + cached.value.sarif, + exportCompletionReductions(cached.value, input.fallbackReduction), + ); + return { + status: 'completed', + durationMs: Date.now() - startedAt, + reused: true, + usage: cached.usage, + artifact: cached.ref, + value: cached.value, + }; + } + + let findings: readonly unknown[] = []; + if (input.findingsArtifact && input.findingsStage) { + const source = await loadArtifactRef( + input.artifactRoot, + input.findingsArtifact, + input.findingsStage, + isRawFindingSetValue, + ); + findings = source.value.findings; + } + + const exported = await exportCapellaFindings(findings, { + artifactRoot: input.artifactRoot, + repositoryLabel: input.repositoryLabel, + codePathAvoids: input.codePathAvoids, + ...(cancellationSignal && { cancellationSignal }), + }); + throwIfCancelled(cancellationSignal); + const value: ExportValue = { + sarif: exported.sarif, + findingCount: exported.findingCount, + coverage: exported.coverage, + warnings: [...exported.warnings], + reportPath: exported.reportPath, + }; + const completed = await completeStage( + input, + 'export', + fingerprint, + ZERO_CAPELLA_USAGE, + value, + identity, + startedAt, + value.warnings, + value.sarif, + fallbackCompletionReductions(input.fallbackReduction), + ); + return completed; +} diff --git a/apps/worker/src/ai/sast/capella/stages/research.ts b/apps/worker/src/ai/sast/capella/stages/research.ts new file mode 100644 index 00000000..d3adc954 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/stages/research.ts @@ -0,0 +1,538 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { resolve } from 'node:path'; +import { Type } from 'typebox'; +import type { AgenticSastResearchReduction } from '../../types.js'; +import { + addUsage, + buildFingerprint, + loadArtifactRef, + loadCompletedArtifact, + publishCheckpointArtifact, + stableJson, +} from '../artifacts.js'; +import { createFindingCollector } from '../collectors.js'; +import type { CapellaFinding } from '../finding-types.js'; +import { buildCodePathScopeSnippet, buildResearchAssignment, RESEARCH_TOOLS, TRIAGE_TOOLS } from '../prompt-context.js'; +import { createCapellaPromptLoader } from '../prompt-loader.js'; +import { type Investigation, TRIAGE_SCHEMA, type TriageResult } from '../schemas.js'; +import { + CAPELLA_AUDIT_CONCURRENCY, + CAPELLA_TRIAGE_CONCURRENCY, + type CapellaStageRuntime, + type CompletedStage, + type ResearchAuditCoverage, + type ResearchCoverage, + type ResearchStageInput, + type ResearchValue, + ZERO_CAPELLA_USAGE, +} from '../types.js'; +import { isArchitectureValue, isFindingSetValue, isPlanValue, isResearchValue, isTriageResult } from '../validation.js'; +import { + artifactLineage, + completeStage, + maybeReuseStage, + publishRawFindingAssets, + resolveStageIdentity, + runCollectorSession, + withTemporaryFindings, +} from './shared.js'; + +const AUDIT_MAX_TURNS = 200; +const TRIAGE_MAX_TURNS = 100; +const TRIAGE_REPAIR_POLICY_VERSION = 1; + +interface TriageCheckpoint { + readonly batchId: string; + readonly files: string[]; + readonly classifications: TriageResult['classifications']; +} + +interface AuditCheckpoint { + readonly investigationId: string; + readonly title: string; + readonly findings: CapellaFinding[]; + readonly salvagedTurnLimit: boolean; +} + +interface ResearchConcurrency { + readonly triage: number; + readonly audit: number; +} + +interface PoolItemResult { + readonly value: T; + readonly reused: boolean; +} + +interface PoolSuccess { + readonly status: 'succeeded'; + readonly value: T; +} + +interface PoolFailure { + readonly status: 'failed'; + readonly error: unknown; +} + +type PoolOutcome = PoolSuccess | PoolFailure; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isTriageCheckpoint(value: unknown): value is TriageCheckpoint { + if ( + !isRecord(value) || + typeof value.batchId !== 'string' || + !Array.isArray(value.files) || + !value.files.every((file) => typeof file === 'string') || + !isTriageResult({ classifications: value.classifications }) + ) { + return false; + } + const classifications = value.classifications as TriageResult['classifications']; + const assigned = new Set(value.files as string[]); + const classified = classifications.map((classification) => classification.file); + // Assigned files are unique, and every stored classification names a distinct assigned path. + // The set may be incomplete (fewer classifications than assigned) but is never inflated by a + // duplicate path or an unexpected path, so reusing it cannot overstate coverage. + return ( + assigned.size === value.files.length && + new Set(classified).size === classified.length && + classified.every((file) => assigned.has(file)) + ); +} + +function isAuditCheckpoint(value: unknown): value is AuditCheckpoint { + return ( + isRecord(value) && + /^[0-9a-f]{20}$/.test(String(value.investigationId)) && + typeof value.title === 'string' && + value.title.length > 0 && + typeof value.salvagedTurnLimit === 'boolean' && + isFindingSetValue({ findings: value.findings }) + ); +} + +function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function batchFiles(files: readonly string[]): string[][] { + if (files.length === 0) return []; + // At most one batch per triage worker. Batch membership feeds the checkpoint + // fingerprints, so for a given file set and concurrency the batches are stable. + const size = Math.max(1, Math.ceil(files.length / CAPELLA_TRIAGE_CONCURRENCY)); + const batches: string[][] = []; + for (let index = 0; index < files.length; index += size) batches.push(files.slice(index, index + size)); + return batches; +} + +function missingAssignedFiles( + assignedFiles: readonly string[], + classifications: TriageResult['classifications'], +): string[] { + const classified = new Set(classifications.map((classification) => classification.file)); + return assignedFiles.filter((file) => !classified.has(file)); +} + +/** + * Fingerprint for the research stage. Concurrency is a real input: triage batch + * membership derives from it, so a different concurrency yields different checkpoint + * fingerprints and must invalidate the stage artifact rather than half-reuse it. + */ +export function buildResearchFingerprint( + runInputFingerprint: string, + renderedPromptsSha256: string, + architectureLineage: Record, + planLineage: Record, + concurrency: ResearchConcurrency = { + triage: CAPELLA_TRIAGE_CONCURRENCY, + audit: CAPELLA_AUDIT_CONCURRENCY, + }, +): string { + return buildFingerprint({ + stage: 'research', + runInputFingerprint, + renderedPromptsSha256, + architecture: architectureLineage, + plan: planLineage, + concurrency, + triageRepairPolicyVersion: TRIAGE_REPAIR_POLICY_VERSION, + }); +} + +/** + * Settle every unit and preserve input order. Callers decide which typed failures + * are tolerable at their own stage boundary. + */ +export async function runSettledPool( + items: readonly T[], + concurrency: number, + run: (item: T, index: number) => Promise, +): Promise>> { + const results: Array> = []; + let cursor = 0; + const workerCount = Math.min(concurrency, items.length); + const workers = Array.from({ length: workerCount }, async () => { + while (true) { + const index = cursor; + cursor += 1; + const item = items[index]; + if (item === undefined) return; + try { + results[index] = { status: 'succeeded', value: await run(item, index) }; + } catch (error) { + results[index] = { status: 'failed', error }; + } + } + }); + await Promise.all(workers); + return results; +} + +function successfulPoolValues(outcomes: readonly PoolOutcome[]): T[] { + return outcomes.flatMap((outcome) => (outcome.status === 'succeeded' ? [outcome.value] : [])); +} + +/** + * Reduce a triage batch's raw classifications to the usable set: one classification per assigned + * path, in first-seen order. Unexpected paths and duplicate classifications are dropped, so they + * can never inflate coverage. A schema-valid batch that omits some assigned files yields fewer + * usable classifications rather than a failure. + */ +export function usableClassifications( + assignedFiles: readonly string[], + classifications: TriageResult['classifications'], +): TriageResult['classifications'] { + const assigned = new Set(assignedFiles); + const seen = new Set(); + const usable: TriageResult['classifications'] = []; + for (const classification of classifications) { + if (!assigned.has(classification.file) || seen.has(classification.file)) continue; + seen.add(classification.file); + usable.push(classification); + } + return usable; +} + +/** + * Compute the deterministic triage-coverage result once, from the exact assigned file set and the + * usable classifications each batch produced. `missingFiles` (sorted) is the private evidence of + * which assigned paths went unclassified. + */ +export function computeTriageCoverage(checkpoints: readonly TriageCheckpoint[]): ResearchCoverage { + const consideredFiles = new Set(); + const classifiedFiles = new Set(); + let affectedBatchCount = 0; + for (const checkpoint of checkpoints) { + for (const file of checkpoint.files) consideredFiles.add(file); + for (const classification of checkpoint.classifications) classifiedFiles.add(classification.file); + if (checkpoint.classifications.length < checkpoint.files.length) affectedBatchCount += 1; + } + const consideredCount = consideredFiles.size; + const classifiedCount = classifiedFiles.size; + return { + consideredCount, + classifiedCount, + omittedCount: consideredCount - classifiedCount, + affectedBatchCount, + missingFiles: [...consideredFiles].filter((file) => !classifiedFiles.has(file)).sort(compareText), + }; +} + +/** Counts-only aggregate research reduction; carries no path, id, or model text. */ +export function buildResearchReduction( + triage: ResearchCoverage, + audit: ResearchAuditCoverage, +): AgenticSastResearchReduction { + return { + stage: 'research', + reason: 'incomplete_research', + triageConsideredCount: triage.consideredCount, + triageClassifiedCount: triage.classifiedCount, + triageOmittedCount: triage.omittedCount, + affectedTriageBatchCount: triage.affectedBatchCount, + auditUnitCount: audit.consideredCount, + salvagedAuditSessionCount: audit.salvagedSessionCount, + }; +} + +function investigationId(investigation: Investigation): string { + return buildFingerprint({ investigation }).slice(0, 20); +} + +function combineFindings(checkpoints: readonly AuditCheckpoint[]): CapellaFinding[] { + // Different investigations can report the same finding id. Ordering by id and then + // serialized body before first-wins insertion makes the surviving body deterministic. + const candidates = checkpoints + .flatMap((checkpoint) => checkpoint.findings) + .sort((left, right) => compareText(left.id, right.id) || compareText(stableJson(left), stableJson(right))); + const byId = new Map(); + for (const finding of candidates) { + if (!byId.has(finding.id)) byId.set(finding.id, finding); + } + return [...byId.values()].sort((left, right) => compareText(left.id, right.id)); +} + +export async function runResearchStage( + input: ResearchStageInput, + runtime: CapellaStageRuntime, +): Promise> { + const startedAt = Date.now(); + const architecture = await loadArtifactRef( + input.artifactRoot, + input.architectureArtifact, + 'architecture', + isArchitectureValue, + ); + const plan = await loadArtifactRef(input.artifactRoot, input.planArtifact, 'plan', isPlanValue); + const identity = await resolveStageIdentity(input); + const loader = createCapellaPromptLoader(input.promptDir); + const scope = buildCodePathScopeSnippet(input.codePathFocus, input.codePathAvoids); + const triageBasePrompt = loader.render( + 'sast.capella.triage', + { ...TRIAGE_TOOLS, LANGUAGE_CONTEXT: '', BOUNDARY_CONTEXT: scope, TARGET_FILES: '' }, + { pipelineTestingMode: input.pipelineTestingMode }, + ); + const auditBasePrompt = loader.render( + 'sast.capella.research', + { ...RESEARCH_TOOLS, LANGUAGE_CONTEXT: '', BOUNDARY_CONTEXT: scope }, + { pipelineTestingMode: input.pipelineTestingMode }, + ); + const renderedPromptsSha256 = buildFingerprint({ triageBasePrompt, auditBasePrompt }); + const fingerprint = buildResearchFingerprint( + identity.runInputFingerprint, + renderedPromptsSha256, + artifactLineage(input.architectureArtifact), + artifactLineage(input.planArtifact), + ); + const reused = await maybeReuseStage(input, 'research', fingerprint, isResearchValue, identity, startedAt); + if (reused) return reused; + + const allFiles = [...new Set(plan.value.investigations.flatMap((investigation) => investigation.target_files))].sort( + compareText, + ); + const batches = batchFiles(allFiles).map((files) => ({ + files, + batchId: buildFingerprint({ files }).slice(0, 20), + })); + + const triageOutcomes = await runSettledPool(batches, CAPELLA_TRIAGE_CONCURRENCY, async (batch) => { + const checkpointPath = resolve(input.artifactRoot, 'research', 'triage', `${batch.batchId}.json`); + const checkpointFingerprint = buildFingerprint({ researchFingerprint: fingerprint, wave: 'triage', ...batch }); + const cached = await loadCompletedArtifact( + input.artifactRoot, + checkpointPath, + 'research', + checkpointFingerprint, + isTriageCheckpoint, + ); + if (cached) return { value: cached.value, usage: cached.usage, reused: true }; + + const userPrompt = `${triageBasePrompt}\n\nAssigned files:\n${batch.files.map((file) => `- ${file}`).join('\n')}`; + const primaryResponse = await runtime.executor.run({ + stage: 'research', + role: 'small', + cwd: input.repoPath, + systemPrompt: 'You are a rapid triage auditor. Classify every assigned file and optimize for recall.', + userPrompt, + maxTurns: TRIAGE_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: runtime.repositoryTools, + outputSchema: Type.Unsafe(TRIAGE_SCHEMA), + signal: runtime.signal, + }); + let usage = primaryResponse.usage; + const primaryIsValid = isTriageResult(primaryResponse.output); + let classifications = primaryIsValid + ? usableClassifications(batch.files, primaryResponse.output.classifications) + : []; + const missingFiles = missingAssignedFiles(batch.files, classifications); + if (missingFiles.length > 0) { + const repairPrompt = [ + triageBasePrompt, + 'Repair pass: the previous session was invalid or omitted the assigned files below. Classify every listed file.', + missingFiles.map((file) => `- ${file}`).join('\n'), + ].join('\n\n'); + const repairResponse = await runtime.executor.run({ + stage: 'research', + role: 'small', + cwd: input.repoPath, + systemPrompt: 'You are a rapid triage repair auditor. Classify every assigned file and optimize for recall.', + userPrompt: repairPrompt, + maxTurns: TRIAGE_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: runtime.repositoryTools, + outputSchema: Type.Unsafe(TRIAGE_SCHEMA), + signal: runtime.signal, + }); + if (isTriageResult(repairResponse.output)) { + const repaired = usableClassifications(missingFiles, repairResponse.output.classifications); + classifications = usableClassifications(batch.files, [...classifications, ...repaired]); + } + usage = addUsage(usage, repairResponse.usage); + } + + // A primary or repair response can remain invalid or incomplete after the one repair session. + // Publish only usable classifications and let the deterministic coverage summary disclose the + // remaining reduction. + const value: TriageCheckpoint = { + batchId: batch.batchId, + files: [...batch.files], + classifications, + }; + await publishCheckpointArtifact( + input.artifactRoot, + checkpointPath, + 'research', + checkpointFingerprint, + usage, + value, + ); + return { value, usage, reused: false }; + }); + + const triageFailure = triageOutcomes.find((outcome) => outcome.status === 'failed'); + if (triageFailure?.status === 'failed') throw triageFailure.error; + const triageResults = successfulPoolValues(triageOutcomes); + + const flaggedFiles = [ + ...new Set( + triageResults.flatMap((result) => + result.value.classifications + .filter((classification) => classification.potentially_flawed) + .map((classification) => classification.file), + ), + ), + ].sort(compareText); + const flaggedSet = new Set(flaggedFiles); + const audits = plan.value.investigations + .map((investigation) => ({ + investigation, + investigationId: investigationId(investigation), + flaggedFiles: investigation.target_files.filter((file) => flaggedSet.has(file)), + })) + .filter((audit) => audit.flaggedFiles.length > 0); + + const auditOutcomes = await runSettledPool(audits, CAPELLA_AUDIT_CONCURRENCY, async (audit) => { + const checkpointPath = resolve(input.artifactRoot, 'research', 'audit', `${audit.investigationId}.json`); + const checkpointFingerprint = buildFingerprint({ + researchFingerprint: fingerprint, + wave: 'audit', + investigationId: audit.investigationId, + flaggedFiles: [...audit.flaggedFiles].sort(compareText), + }); + const cached = await loadCompletedArtifact( + input.artifactRoot, + checkpointPath, + 'research', + checkpointFingerprint, + isAuditCheckpoint, + ); + if (cached) return { value: cached.value, usage: cached.usage, reused: true }; + + const value = await withTemporaryFindings(input.artifactRoot, [], async (findingsDir) => { + const collector = createFindingCollector({ findingsDir }); + const userPrompt = `${auditBasePrompt}\n\n${buildResearchAssignment( + audit.investigation, + audit.flaggedFiles, + architecture.value.knowledgeBase, + )}`; + const session = await runCollectorSession( + () => + runtime.executor.run({ + stage: 'research', + role: 'medium', + cwd: input.repoPath, + systemPrompt: + 'You are a deep security auditor. Audit the assigned hotspots and report each finding through ' + + 'report_finding. A finding without one bare CWE cannot be reported.', + userPrompt, + maxTurns: AUDIT_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: [...runtime.repositoryTools, ...collector.tools], + signal: runtime.signal, + }), + () => collector.getFindings().length, + ); + const checkpoint: AuditCheckpoint = { + investigationId: audit.investigationId, + title: audit.investigation.title, + findings: collector.getFindings().sort((left, right) => compareText(left.id, right.id)), + salvagedTurnLimit: session.salvagedTurnLimit, + }; + return { checkpoint, usage: session.usage }; + }); + await publishCheckpointArtifact( + input.artifactRoot, + checkpointPath, + 'research', + checkpointFingerprint, + value.usage, + value.checkpoint, + ); + return { value: value.checkpoint, usage: value.usage, reused: false }; + }); + + const auditFailure = auditOutcomes.find((outcome) => outcome.status === 'failed'); + if (auditFailure?.status === 'failed') throw auditFailure.error; + const auditResults = successfulPoolValues(auditOutcomes); + + const findings = combineFindings(auditResults.map((result) => result.value)); + // Durable traces of what triage flagged and which investigations actually ran, + // published for inspection of a finished or resumed scan. + await publishCheckpointArtifact( + input.artifactRoot, + resolve(input.artifactRoot, 'research', 'flagged.json'), + 'research', + buildFingerprint({ researchFingerprint: fingerprint, flaggedFiles }), + ZERO_CAPELLA_USAGE, + { flaggedFiles }, + ); + await publishCheckpointArtifact( + input.artifactRoot, + resolve(input.artifactRoot, 'research', 'audited.json'), + 'research', + buildFingerprint({ + researchFingerprint: fingerprint, + investigationIds: auditResults.map((result) => result.value.investigationId), + }), + ZERO_CAPELLA_USAGE, + { investigationIds: auditResults.map((result) => result.value.investigationId).sort(compareText) }, + ); + await publishRawFindingAssets(input, findings); + + const allUnits: Array & { usage: typeof ZERO_CAPELLA_USAGE }> = [ + ...triageResults, + ...auditResults, + ]; + const usage = allUnits.reduce((total, result) => addUsage(total, result.usage), ZERO_CAPELLA_USAGE); + const triageCoverage = computeTriageCoverage(triageResults.map((result) => result.value)); + const auditCoverage: ResearchAuditCoverage = { + consideredCount: audits.length, + completedCount: auditResults.length, + salvagedSessionCount: auditResults.filter((result) => result.value.salvagedTurnLimit).length, + }; + const reduced = triageCoverage.omittedCount > 0 || auditCoverage.salvagedSessionCount > 0; + const coverage = reduced ? 'reduced' : 'complete'; + const reduction = reduced ? buildResearchReduction(triageCoverage, auditCoverage) : undefined; + const value: ResearchValue = { + findings, + flaggedFiles, + dispatchedCount: auditResults.filter((result) => !result.reused).length, + resumedCount: auditResults.filter((result) => result.reused).length, + coverage, + triageCoverage, + auditCoverage, + ...(reduction !== undefined && { reduction }), + }; + return completeStage(input, 'research', fingerprint, usage, value, identity, startedAt); +} diff --git a/apps/worker/src/ai/sast/capella/stages/shared.ts b/apps/worker/src/ai/sast/capella/stages/shared.ts new file mode 100644 index 00000000..c31e51b4 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/stages/shared.ts @@ -0,0 +1,295 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { CapellaAgentError } from '../../../pi/capella-agent-executor.js'; +import type { CapellaAgentResponse } from '../../../pi/capella-agent-types.js'; +import type { AgenticSastReduction, CapellaStage, CapellaUsage, SarifRef } from '../../types.js'; +import { + atomicPublishBytes, + buildFingerprint, + buildRunInputFingerprint, + loadCompletedArtifact, + publishStageArtifact, + recordStageCompletion, + repositoryIdentity, + sha256Bytes, + stableJson, + stageArtifactPath, +} from '../artifacts.js'; +import { SastContractError } from '../errors.js'; +import type { CapellaFinding } from '../finding-types.js'; +import type { CapellaArtifactRef, CapellaStageInput, CompletedStage, StageArtifactValidator } from '../types.js'; +import { isAgenticSastReduction, isCapellaFinding } from '../validation.js'; + +export interface StageIdentity { + readonly repositoryIdentity: string; + readonly runInputFingerprint: string; +} + +function reductionFromStageValue(value: unknown, stage: CapellaStage): AgenticSastReduction | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value) || !('reduction' in value)) return undefined; + const reduction = (value as { readonly reduction?: unknown }).reduction; + if (reduction === undefined) return undefined; + if (!isAgenticSastReduction(reduction) || reduction.stage !== stage) { + throw new SastContractError('Capella stage value carried an invalid reduction', 'REDUCTION_SCHEMA'); + } + return reduction; +} + +function completionReductions( + value: unknown, + stage: CapellaStage, + additionalReductions: readonly AgenticSastReduction[], +): readonly AgenticSastReduction[] { + const stageReduction = reductionFromStageValue(value, stage); + return stageReduction === undefined ? additionalReductions : [stageReduction, ...additionalReductions]; +} + +export async function resolveStageIdentity(input: CapellaStageInput): Promise { + const identity = await repositoryIdentity(input.repoPath); + return { + repositoryIdentity: identity, + runInputFingerprint: buildRunInputFingerprint(input, identity), + }; +} + +/** + * Fingerprint that decides artifact reuse for a stage. Everything that can change the + * stage's model-visible behavior must flow in through the rendered prompt or stageInputs; + * an input missing here lets a stale artifact be adopted on resume. + */ +export function buildStageFingerprint( + stage: CapellaStage, + identity: StageIdentity, + prompt: string, + stageInputs: Record, +): string { + return buildFingerprint({ + stage, + runInputFingerprint: identity.runInputFingerprint, + renderedPromptSha256: sha256Bytes(prompt), + ...stageInputs, + }); +} + +/** + * Adopt a previously published stage artifact when its fingerprint matches. Returns + * undefined on any miss: absent or corrupt artifact, fingerprint mismatch, or a + * reuseGuard veto. The optional reuseGuard re-checks on-disk side effects that the + * artifact envelope cannot see. A hit atomically re-records completion and its + * reduction in run.json, which heals a crash that landed between artifact publication + * and the run-record write. + */ +export async function maybeReuseStage( + input: CapellaStageInput, + stage: CapellaStage, + fingerprint: string, + validate: StageArtifactValidator, + identity: StageIdentity, + startedAt: number, + reuseGuard?: (value: T) => Promise, +): Promise | undefined> { + const loaded = await loadCompletedArtifact( + input.artifactRoot, + stageArtifactPath(input.artifactRoot, stage), + stage, + fingerprint, + validate, + ); + if (!loaded) return undefined; + if (reuseGuard && !(await reuseGuard(loaded.value))) return undefined; + await recordStageCompletion( + input, + identity.runInputFingerprint, + stage, + loaded.usage, + [], + undefined, + completionReductions(loaded.value, stage, []), + ); + return { + status: 'completed', + durationMs: Date.now() - startedAt, + reused: true, + usage: loaded.usage, + artifact: loaded.ref, + value: loaded.value, + }; +} + +/** + * Publish the stage artifact, then atomically record completion and reductions in + * run.json. The order matters: run.json must never name a stage whose artifact is + * missing from disk, while the reverse gap (artifact without record) is healed by + * maybeReuseStage on the next attempt. + */ +export async function completeStage( + input: CapellaStageInput, + stage: CapellaStage, + fingerprint: string, + usage: CapellaUsage, + value: T, + identity: StageIdentity, + startedAt: number, + warnings: readonly string[] = [], + sarif?: SarifRef, + additionalReductions: readonly AgenticSastReduction[] = [], +): Promise> { + const artifact = await publishStageArtifact(input.artifactRoot, stage, fingerprint, usage, value); + await recordStageCompletion( + input, + identity.runInputFingerprint, + stage, + usage, + warnings, + sarif, + completionReductions(value, stage, additionalReductions), + ); + return { + status: 'completed', + durationMs: Date.now() - startedAt, + reused: false, + usage, + artifact, + value, + }; +} + +export async function publishTextAsset(input: CapellaStageInput, relativePath: string, text: string): Promise { + await atomicPublishBytes(input.artifactRoot, resolve(input.artifactRoot, relativePath), text); +} + +function safeFindingFilename(id: string): string { + if (!id || id.includes('/') || id.includes('\\') || id.includes('..')) { + throw new SastContractError('Capella finding id cannot be used as an artifact filename'); + } + return `${id}.json`; +} + +/** Publish readable raw finding files only after the complete research stage succeeds. */ +export async function publishRawFindingAssets( + input: CapellaStageInput, + findings: readonly CapellaFinding[], +): Promise { + const findingsDir = resolve(input.artifactRoot, 'findings'); + const assets = [...findings] + .sort((left, right) => { + if (left.id < right.id) return -1; + if (left.id > right.id) return 1; + return 0; + }) + .map((finding) => ({ finding, path: resolve(findingsDir, safeFindingFilename(finding.id)) })); + // A prior attempt may have published findings whose ids this run no longer produces; + // clearing the directory keeps those orphans out of the published set. + await rm(findingsDir, { recursive: true, force: true }); + for (const asset of assets) { + await atomicPublishBytes(input.artifactRoot, asset.path, stableJson(asset.finding)); + } +} + +/** + * Run a stage against a private scratch copy of the findings so collector tools can + * delete and rewrite files without touching published artifacts. Each attempt gets its + * own directory under .attempts. Cleanup is best-effort: a leftover scratch directory + * is harmless, while a thrown cleanup error would mask the stage result. + */ +export async function withTemporaryFindings( + artifactRoot: string, + findings: readonly CapellaFinding[], + run: (findingsDir: string) => Promise, +): Promise { + const attemptsRoot = resolve(artifactRoot, '.attempts'); + await mkdir(attemptsRoot, { recursive: true }); + const attemptRoot = await mkdtemp(resolve(attemptsRoot, 'stage-')); + const findingsDir = resolve(attemptRoot, 'findings'); + await mkdir(findingsDir, { recursive: true }); + try { + for (const finding of findings) { + await writeFile(resolve(findingsDir, safeFindingFilename(finding.id)), stableJson(finding), { + encoding: 'utf8', + mode: 0o600, + }); + } + return await run(findingsDir); + } finally { + await rm(attemptRoot, { recursive: true, force: true }).catch(() => undefined); + } +} + +export interface CollectorSessionOutcome { + readonly usage: CapellaUsage; + readonly salvagedTurnLimit: boolean; +} + +/** Preserve collector mutations only for a turn-limit that carries usage and accepted work. */ +export async function runCollectorSession( + run: () => Promise>, + acceptedMutationCount: () => number, +): Promise { + const acceptedBefore = acceptedMutationCount(); + try { + const response = await run(); + return { usage: response.usage, salvagedTurnLimit: false }; + } catch (error) { + const acceptedAfter = acceptedMutationCount(); + const canSalvage = + error instanceof CapellaAgentError && + error.code === 'TURN_LIMIT' && + error.usage !== undefined && + acceptedAfter > acceptedBefore; + if (!canSalvage) throw error; + return { usage: error.usage, salvagedTurnLimit: true }; + } +} + +/** + * Read the surviving findings back from a scratch findings directory. Collector tools + * mutate that directory in place while the model works, so the files present after the + * session, and not the collector return values, are the source of truth for survivors. + * Sorted by id so downstream fingerprints stay deterministic. + */ +export interface ActiveFindingOmission { + readonly filename: string; + readonly reason: 'invalid_json' | 'invalid_schema'; +} + +export interface ActiveFindingsResult { + readonly findings: CapellaFinding[]; + readonly omissions: ActiveFindingOmission[]; +} + +export async function readActiveFindings(findingsDir: string): Promise { + const findings: CapellaFinding[] = []; + const omissions: ActiveFindingOmission[] = []; + for (const file of (await readdir(findingsDir)).filter((entry) => entry.endsWith('.json')).sort()) { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(resolve(findingsDir, file), 'utf8')); + } catch { + omissions.push({ filename: file, reason: 'invalid_json' }); + continue; + } + if (!isCapellaFinding(parsed)) { + omissions.push({ filename: file, reason: 'invalid_schema' }); + continue; + } + findings.push(parsed); + } + return { + findings: findings.sort((left, right) => { + if (left.id < right.id) return -1; + if (left.id > right.id) return 1; + return 0; + }), + omissions, + }; +} + +export function artifactLineage(ref: CapellaArtifactRef): Record { + return { path: ref.path, sha256: ref.sha256, fingerprint: ref.fingerprint }; +} diff --git a/apps/worker/src/ai/sast/capella/stages/verdicts.ts b/apps/worker/src/ai/sast/capella/stages/verdicts.ts new file mode 100644 index 00000000..c5518a42 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/stages/verdicts.ts @@ -0,0 +1,608 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { addUsage, loadArtifactRef } from '../artifacts.js'; +import { + createCalibrationCollector, + createConfirmationCollector, + createDuplicateCollector, + createReviewCollector, + createViabilityCollector, + quarantineUngradedReviewFindings, + type VerdictRejectionCounts, +} from '../collectors.js'; +import type { CapellaFinding } from '../finding-types.js'; +import { + buildFindingsContext, + buildKnowledgeBaseContext, + CALIBRATE_TOOLS, + CONFIRM_TOOLS, + CRITIC_TOOLS, + DEDUPE_TOOLS, + REVIEW_TOOLS, +} from '../prompt-context.js'; +import { type CapellaPromptId, createCapellaPromptLoader } from '../prompt-loader.js'; +import type { + CalibrateValue, + CapellaStageRuntime, + CompletedStage, + ConfirmValue, + CriticValue, + DedupeValue, + FindingStageInput, + KnowledgeFindingStageInput, + ResearchValue, + ReviewValue, +} from '../types.js'; +import { + calculateVerdictSetDetails, + isArchitectureValue, + isCalibrateValue, + isConfirmValue, + isCriticValue, + isDedupeValue, + isResearchValue, + isReviewValue, + isThreatModelValue, +} from '../validation.js'; +import { + type ActiveFindingsResult, + artifactLineage, + buildStageFingerprint, + completeStage, + maybeReuseStage, + readActiveFindings, + resolveStageIdentity, + runCollectorSession, + withTemporaryFindings, +} from './shared.js'; + +const DEDUPE_MAX_TURNS = 200; +const REVIEW_MAX_TURNS = 400; +const CRITIC_MAX_TURNS = 300; +const CONFIRM_MAX_TURNS = 300; +const CALIBRATE_MAX_TURNS = 200; + +interface VerdictReductionCounts { + readonly consideredCount: number; + readonly gradedCount: number; + readonly missingCount: number; + readonly unreadableCount: number; + readonly rejectedUnexpectedCount: number; + readonly rejectedDuplicateCount: number; + readonly salvagedTurnLimitCount: number; +} + +function verdictReductionCounts( + expectedIds: readonly string[], + acceptedIds: readonly string[], + active: ActiveFindingsResult, + rejected: VerdictRejectionCounts, + salvagedTurnLimitCount: number, +): VerdictReductionCounts { + const details = calculateVerdictSetDetails(expectedIds, acceptedIds); + return { + consideredCount: expectedIds.length, + gradedCount: acceptedIds.length, + missingCount: details.missingIds.length, + unreadableCount: active.omissions.length, + rejectedUnexpectedCount: rejected.unexpected, + rejectedDuplicateCount: rejected.duplicate, + salvagedTurnLimitCount, + }; +} + +function verdictWasReduced(counts: VerdictReductionCounts): boolean { + return counts.missingCount > 0 || counts.unreadableCount > 0 || counts.salvagedTurnLimitCount > 0; +} + +function renderFindingsPrompt( + input: FindingStageInput, + promptId: CapellaPromptId, + context: Readonly>, + findings: readonly CapellaFinding[], + extraContext = '', +): string { + const loader = createCapellaPromptLoader(input.promptDir); + return [ + loader.render(promptId, context, { pipelineTestingMode: input.pipelineTestingMode }), + buildFindingsContext(findings), + extraContext, + ] + .filter(Boolean) + .join('\n\n'); +} + +function renderVerdictRepairPrompt( + input: FindingStageInput, + promptId: CapellaPromptId, + context: Readonly>, + missingFindings: readonly CapellaFinding[], + extraContext = '', +): string { + return [ + 'Repair pass: submit exactly one decision for every finding below. Do not submit any other finding ID.', + renderFindingsPrompt(input, promptId, context, missingFindings, extraContext), + ].join('\n\n'); +} + +function missingFindings( + expectedFindings: readonly CapellaFinding[], + acceptedIds: readonly string[], +): CapellaFinding[] { + const expectedIds = expectedFindings.map((finding) => finding.id); + const missingIds = new Set(calculateVerdictSetDetails(expectedIds, acceptedIds).missingIds); + return expectedFindings.filter((finding) => missingIds.has(finding.id)); +} + +async function loadResearchFindings(input: FindingStageInput): Promise { + return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'research', isResearchValue)).value; +} + +async function loadDedupeFindings(input: FindingStageInput): Promise { + return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'dedupe', isDedupeValue)).value; +} + +async function loadReviewFindings(input: FindingStageInput): Promise { + return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'review', isReviewValue)).value; +} + +async function loadCriticFindings(input: FindingStageInput): Promise { + return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'critic', isCriticValue)).value; +} + +async function loadConfirmFindings(input: FindingStageInput): Promise { + return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'confirm', isConfirmValue)).value; +} + +async function knowledgeContext(input: KnowledgeFindingStageInput): Promise { + const architecture = await loadArtifactRef( + input.artifactRoot, + input.architectureArtifact, + 'architecture', + isArchitectureValue, + ); + const threatModel = await loadArtifactRef( + input.artifactRoot, + input.threatModelArtifact, + 'threat-model', + isThreatModelValue, + ); + return `${buildKnowledgeBaseContext(architecture.value.knowledgeBase)}\n\n\n${threatModel.value.threatModel}\n`; +} + +export async function runDedupeStage( + input: FindingStageInput, + runtime: CapellaStageRuntime, +): Promise> { + const startedAt = Date.now(); + const source = await loadResearchFindings(input); + const identity = await resolveStageIdentity(input); + const prompt = renderFindingsPrompt(input, 'sast.capella.dedupe', DEDUPE_TOOLS, source.findings); + const fingerprint = buildStageFingerprint('dedupe', identity, prompt, { + findings: artifactLineage(input.findingsArtifact), + }); + const reused = await maybeReuseStage(input, 'dedupe', fingerprint, isDedupeValue, identity, startedAt); + if (reused) return reused; + + const outcome = await withTemporaryFindings(input.artifactRoot, source.findings, async (findingsDir) => { + const collector = createDuplicateCollector({ findingsDir }); + const session = await runCollectorSession( + () => + runtime.executor.run({ + stage: 'dedupe', + role: 'small', + cwd: input.repoPath, + systemPrompt: + 'You consolidate duplicate security findings. Findings at different lines in the same file are distinct.', + userPrompt: prompt, + maxTurns: DEDUPE_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: [...runtime.repositoryTools, ...collector.tools], + signal: runtime.signal, + }), + () => collector.getDuplicates().length, + ); + const active = await readActiveFindings(findingsDir); + const salvagedTurnLimitCount = session.salvagedTurnLimit ? 1 : 0; + const reduced = active.omissions.length > 0 || salvagedTurnLimitCount > 0; + const value: DedupeValue = { + findings: active.findings, + duplicateCount: collector.getDuplicates().length, + survivorCount: active.findings.length, + ...(reduced && { + reduction: { + stage: 'dedupe', + reason: 'incomplete_dedupe', + consideredCount: source.findings.length, + survivorCount: active.findings.length, + unreadableCount: active.omissions.length, + salvagedTurnLimitCount, + }, + }), + }; + return { usage: session.usage, value }; + }); + return completeStage(input, 'dedupe', fingerprint, outcome.usage, outcome.value, identity, startedAt); +} + +export async function runReviewStage( + input: FindingStageInput, + runtime: CapellaStageRuntime, +): Promise> { + const startedAt = Date.now(); + const source = await loadDedupeFindings(input); + const identity = await resolveStageIdentity(input); + const prompt = renderFindingsPrompt(input, 'sast.capella.review', REVIEW_TOOLS, source.findings); + const fingerprint = buildStageFingerprint('review', identity, prompt, { + findings: artifactLineage(input.findingsArtifact), + }); + const reused = await maybeReuseStage(input, 'review', fingerprint, isReviewValue, identity, startedAt); + if (reused) return reused; + const expectedIds = source.findings.map((finding) => finding.id); + + const outcome = await withTemporaryFindings(input.artifactRoot, source.findings, async (findingsDir) => { + const collector = createReviewCollector({ findingsDir, expectedIds }); + const systemPrompt = + "You are the independent validator. Assume every finding is false until the source disproves it. Ignore the finder's prose reasoning."; + const primary = await runCollectorSession( + () => + runtime.executor.run({ + stage: 'review', + role: 'medium', + cwd: input.repoPath, + systemPrompt, + userPrompt: prompt, + maxTurns: REVIEW_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: [...runtime.repositoryTools, ...collector.tools], + signal: runtime.signal, + }), + () => collector.getAcceptedIds().length, + ); + let usage = primary.usage; + let salvagedTurnLimitCount = primary.salvagedTurnLimit ? 1 : 0; + const missing = missingFindings(source.findings, collector.getAcceptedIds()); + if (missing.length > 0) { + const repair = await runCollectorSession( + () => + runtime.executor.run({ + stage: 'review', + role: 'medium', + cwd: input.repoPath, + systemPrompt, + userPrompt: renderVerdictRepairPrompt(input, 'sast.capella.review', REVIEW_TOOLS, missing), + maxTurns: REVIEW_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: [...runtime.repositoryTools, ...collector.tools], + signal: runtime.signal, + }), + () => collector.getAcceptedIds().length, + ); + usage = addUsage(usage, repair.usage); + if (repair.salvagedTurnLimit) salvagedTurnLimitCount += 1; + } + const verdicts = collector.getVerdicts(); + const details = calculateVerdictSetDetails(expectedIds, collector.getAcceptedIds()); + const quarantinedCount = quarantineUngradedReviewFindings(findingsDir, details.missingIds); + const active = await readActiveFindings(findingsDir); + const rejected = collector.getRejectionCounts(); + const reductionCounts = verdictReductionCounts( + expectedIds, + collector.getAcceptedIds(), + active, + rejected, + salvagedTurnLimitCount, + ); + const value: ReviewValue = { + findings: active.findings, + validCount: verdicts.filter((verdict) => verdict.status === 'VALID').length, + provisionalCount: verdicts.filter((verdict) => verdict.status === 'PROVISIONALLY_VALID').length, + falsePositiveCount: verdicts.filter((verdict) => verdict.status === 'FALSE_POSITIVE').length, + rejectedUnexpectedCount: rejected.unexpected, + rejectedDuplicateCount: rejected.duplicate, + ...(verdictWasReduced(reductionCounts) && { + reduction: { + stage: 'review', + reason: 'incomplete_review', + ...reductionCounts, + quarantinedCount, + }, + }), + }; + return { usage, value }; + }); + return completeStage(input, 'review', fingerprint, outcome.usage, outcome.value, identity, startedAt); +} + +export async function runCriticStage( + input: KnowledgeFindingStageInput, + runtime: CapellaStageRuntime, +): Promise> { + const startedAt = Date.now(); + const source = await loadReviewFindings(input); + const kbContext = await knowledgeContext(input); + const identity = await resolveStageIdentity(input); + const prompt = renderFindingsPrompt( + input, + 'sast.capella.critic', + { ...CRITIC_TOOLS, KB_DIR: 'the host-provided context below' }, + source.findings, + kbContext, + ); + const fingerprint = buildStageFingerprint('critic', identity, prompt, { + findings: artifactLineage(input.findingsArtifact), + architecture: artifactLineage(input.architectureArtifact), + threatModel: artifactLineage(input.threatModelArtifact), + }); + const reused = await maybeReuseStage(input, 'critic', fingerprint, isCriticValue, identity, startedAt); + if (reused) return reused; + // The model sees every finding for context, but a viability verdict is owed only + // for the findings that survived review. + const expected = source.findings.filter( + (finding) => finding.status === 'VALID' || finding.status === 'PROVISIONALLY_VALID', + ); + + const outcome = await withTemporaryFindings(input.artifactRoot, source.findings, async (findingsDir) => { + const expectedIds = expected.map((finding) => finding.id); + const collector = createViabilityCollector({ findingsDir, expectedIds }); + const systemPrompt = + 'You are the production-viability expert. Adopt a skeptical stance and independently re-verify each survivor.'; + const primary = await runCollectorSession( + () => + runtime.executor.run({ + stage: 'critic', + role: 'medium', + cwd: input.repoPath, + systemPrompt, + userPrompt: prompt, + maxTurns: CRITIC_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: [...runtime.repositoryTools, ...collector.tools], + signal: runtime.signal, + }), + () => collector.getAcceptedIds().length, + ); + let usage = primary.usage; + let salvagedTurnLimitCount = primary.salvagedTurnLimit ? 1 : 0; + const missing = missingFindings(expected, collector.getAcceptedIds()); + if (missing.length > 0) { + const repair = await runCollectorSession( + () => + runtime.executor.run({ + stage: 'critic', + role: 'medium', + cwd: input.repoPath, + systemPrompt, + userPrompt: renderVerdictRepairPrompt( + input, + 'sast.capella.critic', + { ...CRITIC_TOOLS, KB_DIR: 'the host-provided context below' }, + missing, + kbContext, + ), + maxTurns: CRITIC_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: [...runtime.repositoryTools, ...collector.tools], + signal: runtime.signal, + }), + () => collector.getAcceptedIds().length, + ); + usage = addUsage(usage, repair.usage); + if (repair.salvagedTurnLimit) salvagedTurnLimitCount += 1; + } + const viabilities = collector.getViabilities(); + const active = await readActiveFindings(findingsDir); + const rejected = collector.getRejectionCounts(); + const reductionCounts = verdictReductionCounts( + expectedIds, + collector.getAcceptedIds(), + active, + rejected, + salvagedTurnLimitCount, + ); + const value: CriticValue = { + findings: active.findings, + viableCount: viabilities.filter( + (verdict) => verdict.viability === 'VIABLE' || verdict.viability === 'CONDITIONAL_VIABLE', + ).length, + rejectedUnexpectedCount: rejected.unexpected, + rejectedDuplicateCount: rejected.duplicate, + ...(verdictWasReduced(reductionCounts) && { + reduction: { stage: 'critic', reason: 'incomplete_critic', ...reductionCounts }, + }), + }; + return { usage, value }; + }); + return completeStage(input, 'critic', fingerprint, outcome.usage, outcome.value, identity, startedAt); +} + +export async function runConfirmStage( + input: FindingStageInput, + runtime: CapellaStageRuntime, +): Promise> { + const startedAt = Date.now(); + const source = await loadCriticFindings(input); + const identity = await resolveStageIdentity(input); + const prompt = renderFindingsPrompt(input, 'sast.capella.confirm', CONFIRM_TOOLS, source.findings); + const fingerprint = buildStageFingerprint('confirm', identity, prompt, { + findings: artifactLineage(input.findingsArtifact), + }); + const reused = await maybeReuseStage(input, 'confirm', fingerprint, isConfirmValue, identity, startedAt); + if (reused) return reused; + // Critic assigns production viability without changing status, so the set owed a + // confirmation verdict is still the review survivors. + const expected = source.findings.filter( + (finding) => finding.status === 'VALID' || finding.status === 'PROVISIONALLY_VALID', + ); + + const outcome = await withTemporaryFindings(input.artifactRoot, source.findings, async (findingsDir) => { + const expectedIds = expected.map((finding) => finding.id); + const collector = createConfirmationCollector({ findingsDir, expectedIds }); + const systemPrompt = + 'You statically confirm survivors against source. This engine has no execution sandbox, so reached-sink source evidence is required.'; + const primary = await runCollectorSession( + () => + runtime.executor.run({ + stage: 'confirm', + role: 'medium', + cwd: input.repoPath, + systemPrompt, + userPrompt: prompt, + maxTurns: CONFIRM_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: [...runtime.repositoryTools, ...collector.tools], + signal: runtime.signal, + }), + () => collector.getAcceptedIds().length, + ); + let usage = primary.usage; + let salvagedTurnLimitCount = primary.salvagedTurnLimit ? 1 : 0; + const missing = missingFindings(expected, collector.getAcceptedIds()); + if (missing.length > 0) { + const repair = await runCollectorSession( + () => + runtime.executor.run({ + stage: 'confirm', + role: 'medium', + cwd: input.repoPath, + systemPrompt, + userPrompt: renderVerdictRepairPrompt(input, 'sast.capella.confirm', CONFIRM_TOOLS, missing), + maxTurns: CONFIRM_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: [...runtime.repositoryTools, ...collector.tools], + signal: runtime.signal, + }), + () => collector.getAcceptedIds().length, + ); + usage = addUsage(usage, repair.usage); + if (repair.salvagedTurnLimit) salvagedTurnLimitCount += 1; + } + const confirmations = collector.getConfirmations(); + const active = await readActiveFindings(findingsDir); + const rejected = collector.getRejectionCounts(); + const reductionCounts = verdictReductionCounts( + expectedIds, + collector.getAcceptedIds(), + active, + rejected, + salvagedTurnLimitCount, + ); + const value: ConfirmValue = { + findings: active.findings, + confirmedCount: confirmations.filter((confirmation) => confirmation.promoted).length, + rejectedUnexpectedCount: rejected.unexpected, + rejectedDuplicateCount: rejected.duplicate, + ...(verdictWasReduced(reductionCounts) && { + reduction: { stage: 'confirm', reason: 'incomplete_confirm', ...reductionCounts }, + }), + }; + return { usage, value }; + }); + return completeStage(input, 'confirm', fingerprint, outcome.usage, outcome.value, identity, startedAt); +} + +export async function runCalibrateStage( + input: KnowledgeFindingStageInput, + runtime: CapellaStageRuntime, +): Promise> { + const startedAt = Date.now(); + const source = await loadConfirmFindings(input); + const kbContext = await knowledgeContext(input); + const identity = await resolveStageIdentity(input); + const prompt = renderFindingsPrompt( + input, + 'sast.capella.calibrate', + { ...CALIBRATE_TOOLS, KB_DIR: 'the host-provided context below' }, + source.findings, + kbContext, + ); + const fingerprint = buildStageFingerprint('calibrate', identity, prompt, { + findings: artifactLineage(input.findingsArtifact), + architecture: artifactLineage(input.architectureArtifact), + threatModel: artifactLineage(input.threatModelArtifact), + }); + const reused = await maybeReuseStage(input, 'calibrate', fingerprint, isCalibrateValue, identity, startedAt); + if (reused) return reused; + // Calibration covers review survivors that remain viable in production. This allow-list + // keeps future statuses out until they are explicitly made reportable. + const expected = source.findings.filter( + (finding) => + (finding.status === 'VALID' || finding.status === 'PROVISIONALLY_VALID') && + finding.production_viability !== 'NON_VIABLE', + ); + + const outcome = await withTemporaryFindings(input.artifactRoot, source.findings, async (findingsDir) => { + const expectedIds = expected.map((finding) => finding.id); + const collector = createCalibrationCollector({ findingsDir, expectedIds }); + const systemPrompt = + 'You calibrate report-only risk scores. Do not change exported severity, status, or export eligibility.'; + const primary = await runCollectorSession( + () => + runtime.executor.run({ + stage: 'calibrate', + role: 'small', + cwd: input.repoPath, + systemPrompt, + userPrompt: prompt, + maxTurns: CALIBRATE_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: [...runtime.repositoryTools, ...collector.tools], + signal: runtime.signal, + }), + () => collector.getAcceptedIds().length, + ); + let usage = primary.usage; + let salvagedTurnLimitCount = primary.salvagedTurnLimit ? 1 : 0; + const missing = missingFindings(expected, collector.getAcceptedIds()); + if (missing.length > 0) { + const repair = await runCollectorSession( + () => + runtime.executor.run({ + stage: 'calibrate', + role: 'small', + cwd: input.repoPath, + systemPrompt, + userPrompt: renderVerdictRepairPrompt( + input, + 'sast.capella.calibrate', + { ...CALIBRATE_TOOLS, KB_DIR: 'the host-provided context below' }, + missing, + kbContext, + ), + maxTurns: CALIBRATE_MAX_TURNS, + timeoutMs: input.timeoutMs, + tools: [...runtime.repositoryTools, ...collector.tools], + signal: runtime.signal, + }), + () => collector.getAcceptedIds().length, + ); + usage = addUsage(usage, repair.usage); + if (repair.salvagedTurnLimit) salvagedTurnLimitCount += 1; + } + const calibrations = collector.getCalibrations(); + const active = await readActiveFindings(findingsDir); + const rejected = collector.getRejectionCounts(); + const reductionCounts = verdictReductionCounts( + expectedIds, + collector.getAcceptedIds(), + active, + rejected, + salvagedTurnLimitCount, + ); + const value: CalibrateValue = { + findings: active.findings, + calibratedCount: calibrations.length, + rejectedUnexpectedCount: rejected.unexpected, + rejectedDuplicateCount: rejected.duplicate, + ...(verdictWasReduced(reductionCounts) && { + reduction: { stage: 'calibrate', reason: 'incomplete_calibrate', ...reductionCounts }, + }), + }; + return { usage, value }; + }); + return completeStage(input, 'calibrate', fingerprint, outcome.usage, outcome.value, identity, startedAt); +} diff --git a/apps/worker/src/ai/sast/capella/temporal/activities.ts b/apps/worker/src/ai/sast/capella/temporal/activities.ts new file mode 100644 index 00000000..9b657ab8 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/temporal/activities.ts @@ -0,0 +1,808 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { createHash } from 'node:crypto'; +import type { Dirent } from 'node:fs'; +import { mkdir, open, readdir, readFile, realpath } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { ApplicationFailure, CancelledFailure, Context, heartbeat } from '@temporalio/activity'; +import { CapellaAgentError, capellaAgentExecutor } from '../../../pi/capella-agent-executor.js'; +import type { + CapellaAgentExecutor, + CapellaAgentRequest, + CapellaAgentResponse, +} from '../../../pi/capella-agent-types.js'; +import type { CapellaStage, CapellaUsage } from '../../types.js'; +import { + buildRunInputFingerprint, + recordRunFailure, + recordStageUsageAccounting, + repositoryIdentity, + stableJson, +} from '../artifacts.js'; +import { + CapellaRetryableError, + ConfigurationError, + capellaClassifiedFailureCode, + capellaFailureCode, + InvalidInputError, + SastContractError, +} from '../errors.js'; +import { capellaSafeFailureMessage } from '../safe-failures.js'; +import { runArchitectureStage, runPlanStage, runThreatModelStage } from '../stages/architecture.js'; +import { runExportStage } from '../stages/export.js'; +import { runResearchStage } from '../stages/research.js'; +import { + runCalibrateStage, + runConfirmStage, + runCriticStage, + runDedupeStage, + runReviewStage, +} from '../stages/verdicts.js'; +import { createCapellaRepositoryTools } from '../tools/repository-tools.js'; +import type { CapellaStageInput, CapellaStageRuntime, CompletedStage, StageUsageSummary } from '../types.js'; +import { usageAccountingWarning, ZERO_CAPELLA_USAGE } from '../types.js'; +import { + CAPELLA_ACTIVITY_POLICIES, + type CapellaActivityFailureDetails, + type CapellaActivityInput, + type CapellaActivityResult, + type CapellaArchitectureActivityResult, + type CapellaCalibrateActivityResult, + type CapellaConfirmActivityResult, + type CapellaCriticActivityResult, + type CapellaDedupeActivityResult, + type CapellaExportActivityInput, + type CapellaExportActivityResult, + type CapellaFindingActivityInput, + type CapellaKnowledgeFindingActivityInput, + type CapellaPlanActivityInput, + type CapellaPlanActivityResult, + type CapellaResearchActivityInput, + type CapellaResearchActivityResult, + type CapellaReviewActivityResult, + type CapellaThreatModelActivityInput, + type CapellaThreatModelActivityResult, +} from './activity-types.js'; + +// Must stay well under the smallest policy heartbeatTimeoutMs (one minute, for export). +const HEARTBEAT_INTERVAL_MS = 2_000; +const USAGE_RECORD_SCHEMA_VERSION = 1; + +/** + * Scan-local directories that the pentest writes into the target repository while Capella is + * reading it. Each pattern denies the directory and everything beneath it. These are + * confinement-only: they never join the user's avoid rules, prompts, export filtering, input + * fingerprints, or public configuration. + */ +const CONFINEMENT_ONLY_DENIED_PATHS: readonly string[] = Object.freeze(['.shannon/**', '.playwright/**']); + +interface UsageRecordIdentity { + readonly inputFingerprint: string; + readonly stage: CapellaStage; + readonly executionKey: string; + readonly attempt: number; + readonly workloadId: string; + readonly sessionNumber: number; +} + +interface StartedUsageRecord extends UsageRecordIdentity { + readonly schemaVersion: 1; + readonly state: 'started'; +} + +interface FinalUsageRecord extends UsageRecordIdentity { + readonly schemaVersion: 1; + readonly state: 'final'; + readonly usage: CapellaUsage; + readonly complete: boolean; +} + +interface ActivityAttemptRecord { + readonly schemaVersion: 1; + readonly state: 'activity-attempt'; + readonly inputFingerprint: string; + readonly stage: CapellaStage; + readonly executionKey: string; + readonly attempt: number; +} + +interface ClassifiedActivityError { + readonly type: string; + readonly code: string; + readonly retryable: boolean; + readonly message: string; +} + +type StageRunner = (runtime: CapellaStageRuntime) => Promise>; + +function addUsage(left: CapellaUsage, right: CapellaUsage): CapellaUsage { + return { + inputTokens: left.inputTokens + right.inputTokens, + outputTokens: left.outputTokens + right.outputTokens, + cacheReadTokens: left.cacheReadTokens + right.cacheReadTokens, + cacheWriteTokens: left.cacheWriteTokens + right.cacheWriteTokens, + costUsd: left.costUsd + right.costUsd, + turns: left.turns + right.turns, + }; +} + +function isUsage(value: unknown): value is CapellaUsage { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + const integerFields = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'turns']; + return ( + integerFields.every((field) => Number.isSafeInteger(record[field]) && Number(record[field]) >= 0) && + typeof record.costUsd === 'number' && + Number.isFinite(record.costUsd) && + record.costUsd >= 0 + ); +} + +function sha256Parts(...parts: readonly string[]): string { + const hash = createHash('sha256'); + for (const part of parts) { + hash.update(part); + hash.update('\0'); + } + return hash.digest('hex'); +} + +function usageRecordDirectory(artifactRoot: string, identity: UsageRecordIdentity): string { + return resolve( + artifactRoot, + '.usage', + identity.inputFingerprint, + identity.stage, + identity.executionKey, + `attempt-${identity.attempt}`, + ); +} + +function usageRecordPath(artifactRoot: string, identity: UsageRecordIdentity, state: 'started' | 'final'): string { + return resolve( + usageRecordDirectory(artifactRoot, identity), + `${identity.workloadId}-${identity.sessionNumber}.${state}.json`, + ); +} + +/** + * Write-once ledger entry. Opening with 'wx' plus a byte comparison on EEXIST makes + * retries idempotent: an identical replay is adopted silently, while different bytes + * under the same identity mean two executions claimed one slot, which is terminal. + * Transient I/O surfaces as retryable so Temporal re-drives the attempt. + */ +async function writeImmutableUsageRecord( + artifactRoot: string, + identity: UsageRecordIdentity, + record: StartedUsageRecord | FinalUsageRecord, +): Promise { + const directory = usageRecordDirectory(artifactRoot, identity); + const path = usageRecordPath(artifactRoot, identity, record.state); + const bytes = stableJson(record); + await mkdir(directory, { recursive: true }); + let handle: Awaited> | undefined; + try { + handle = await open(path, 'wx', 0o600); + await handle.writeFile(bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + } catch (error) { + await handle?.close().catch(() => undefined); + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + let existing: string; + try { + existing = await readFile(path, 'utf8'); + } catch { + throw new CapellaRetryableError('Capella usage record could not be verified', 'USAGE_LEDGER_IO'); + } + if (existing === bytes) return; + throw new SastContractError('Capella usage record conflicts with immutable bytes', 'USAGE_RECORD_CONFLICT'); + } + throw new CapellaRetryableError('Capella usage record could not be published', 'USAGE_LEDGER_IO'); + } +} + +/** Same write-once discipline as usage records, keyed by activity attempt so retries stay visible in the ledger. */ +async function writeActivityAttemptRecord(artifactRoot: string, record: ActivityAttemptRecord): Promise { + const directory = resolve(artifactRoot, '.usage', record.inputFingerprint, record.stage, 'activity-attempts'); + const path = resolve(directory, `${record.executionKey}-${record.attempt}.activity-attempt.json`); + const bytes = stableJson(record); + await mkdir(directory, { recursive: true }); + let handle: Awaited> | undefined; + try { + handle = await open(path, 'wx', 0o600); + await handle.writeFile(bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + } catch (error) { + await handle?.close().catch(() => undefined); + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + let existing: string; + try { + existing = await readFile(path, 'utf8'); + } catch { + throw new CapellaRetryableError('Capella attempt record could not be verified', 'ATTEMPT_LEDGER_IO'); + } + if (existing === bytes) return; + throw new SastContractError('Capella attempt record conflicts with immutable bytes', 'ATTEMPT_RECORD_CONFLICT'); + } + throw new CapellaRetryableError('Capella attempt record could not be published', 'ATTEMPT_LEDGER_IO'); + } +} + +function isFinalUsageRecord(value: unknown, inputFingerprint: string, stage: CapellaStage): value is FinalUsageRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return ( + record.schemaVersion === USAGE_RECORD_SCHEMA_VERSION && + record.state === 'final' && + record.inputFingerprint === inputFingerprint && + record.stage === stage && + typeof record.executionKey === 'string' && + /^[0-9a-f]{32}$/.test(record.executionKey) && + Number.isSafeInteger(record.attempt) && + Number(record.attempt) >= 1 && + typeof record.workloadId === 'string' && + /^[0-9a-f]{32}$/.test(record.workloadId) && + Number.isSafeInteger(record.sessionNumber) && + Number(record.sessionNumber) >= 1 && + typeof record.complete === 'boolean' && + isUsage(record.usage) + ); +} + +async function collectFiles(directory: string): Promise { + let entries: Dirent[]; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + + const files: string[] = []; + for (const entry of entries) { + const child = resolve(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectFiles(child))); + } else if (entry.isFile()) { + files.push(child); + } + } + return files; +} + +/** + * Fold the stage's ledger into a spend summary. `complete` requires a valid, matching + * final record for every started one. `retried` reports whether more than one activity + * attempt touched the stage; callers downgrade usageComplete on any retry because an + * attempt that died mid-session cannot prove its spend was fully captured. + */ +async function aggregateStageUsage( + artifactRoot: string, + inputFingerprint: string, + stage: CapellaStage, +): Promise { + const root = resolve(artifactRoot, '.usage', inputFingerprint, stage); + const files = await collectFiles(root); + const started = files.filter((path) => path.endsWith('.started.json')); + const final = files.filter((path) => path.endsWith('.final.json')); + const activityAttempts = files.filter((path) => path.endsWith('.activity-attempt.json')); + let usage = ZERO_CAPELLA_USAGE; + let complete = started.length === final.length; + let retried = activityAttempts.length > 1; + + for (const path of activityAttempts.sort()) { + try { + const parsed: unknown = JSON.parse(await readFile(path, 'utf8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + complete = false; + continue; + } + const record = parsed as Record; + if ( + record.schemaVersion !== USAGE_RECORD_SCHEMA_VERSION || + record.state !== 'activity-attempt' || + record.inputFingerprint !== inputFingerprint || + record.stage !== stage || + typeof record.executionKey !== 'string' || + !/^[0-9a-f]{32}$/.test(record.executionKey) || + !Number.isSafeInteger(record.attempt) || + Number(record.attempt) < 1 + ) { + complete = false; + continue; + } + retried ||= Number(record.attempt) > 1; + } catch { + complete = false; + } + } + + for (const path of final.sort()) { + try { + const parsed: unknown = JSON.parse(await readFile(path, 'utf8')); + if (!isFinalUsageRecord(parsed, inputFingerprint, stage)) { + complete = false; + continue; + } + usage = addUsage(usage, parsed.usage); + complete &&= parsed.complete; + } catch { + complete = false; + } + } + return { usage, complete, retried }; +} + +function usageFromError(error: unknown): CapellaUsage | undefined { + if (error instanceof CapellaAgentError && error.usage) return error.usage; + if (!error || typeof error !== 'object' || !('usage' in error)) return undefined; + const usage = (error as { readonly usage?: unknown }).usage; + return isUsage(usage) ? usage : undefined; +} + +/** + * Decorates the Capella agent executor with ledger writes on both sides of every + * session. workloadId identifies the logical model call (stage, role, prompts) across + * attempts; sessionNumber separates repeats of that call within one activity attempt. + */ +class UsageRecordingExecutor implements CapellaAgentExecutor { + private readonly sessionCounts = new Map(); + + constructor( + private readonly delegate: CapellaAgentExecutor, + private readonly artifactRoot: string, + private readonly baseIdentity: Omit, + ) {} + + async run(request: CapellaAgentRequest): Promise> { + const workloadId = sha256Parts(request.stage, request.role, request.systemPrompt, request.userPrompt).slice(0, 32); + const sessionNumber = (this.sessionCounts.get(workloadId) ?? 0) + 1; + this.sessionCounts.set(workloadId, sessionNumber); + const identity: UsageRecordIdentity = { ...this.baseIdentity, workloadId, sessionNumber }; + const started: StartedUsageRecord = { + schemaVersion: USAGE_RECORD_SCHEMA_VERSION, + state: 'started', + ...identity, + }; + await writeImmutableUsageRecord(this.artifactRoot, identity, started); + + let response: CapellaAgentResponse | undefined; + let caught: unknown; + try { + response = await this.delegate.run(request); + } catch (error) { + caught = error; + } + + const errorUsage = usageFromError(caught); + const final: FinalUsageRecord = { + schemaVersion: USAGE_RECORD_SCHEMA_VERSION, + state: 'final', + ...identity, + usage: response?.usage ?? errorUsage ?? ZERO_CAPELLA_USAGE, + complete: response !== undefined || errorUsage !== undefined, + }; + try { + await writeImmutableUsageRecord(this.artifactRoot, identity, final); + } catch (recordError) { + // A ledger failure after a successful session fails the activity: using the + // response while its spend went unrecorded would corrupt accounting. When the + // session itself already failed, the original error stays primary and the + // ledger gap surfaces later as incomplete usage. + if (caught === undefined) { + const terminal = recordError instanceof SastContractError; + throw new CapellaAgentError( + terminal ? 'SastContractError' : 'AgentExecutionError', + 'USAGE_LEDGER_FAILURE', + terminal ? 'Capella usage ledger conflicted with immutable bytes.' : 'Capella usage ledger write failed.', + !terminal, + response?.usage, + ); + } + } + + if (caught !== undefined) throw caught; + if (!response) throw new SastContractError('Capella executor returned no response'); + return response; + } +} + +function classifyActivityError(error: unknown): ClassifiedActivityError { + if (error instanceof CapellaAgentError) { + return { + type: error.name, + code: capellaClassifiedFailureCode(error.code, error.providerCategory), + retryable: error.retryable, + message: capellaSafeFailureMessage(error.name), + }; + } + // Matched by name so this layer stays independent of the provider harness's error classes. + if (error instanceof Error && error.name === 'AuthenticationError') { + return { + type: 'AuthenticationError', + code: 'AUTHENTICATION', + retryable: false, + message: capellaSafeFailureMessage('AuthenticationError'), + }; + } + if (error instanceof CapellaRetryableError) { + return { + type: 'AgentExecutionError', + code: error.code, + retryable: true, + message: capellaSafeFailureMessage('AgentExecutionError'), + }; + } + // A confinement violation means the model requested a path outside its granted + // root; retrying the same request cannot succeed. + if (error instanceof Error && error.name === 'ConfinementError') { + return { + type: 'InvalidInputError', + code: 'CONFINEMENT', + retryable: false, + message: capellaSafeFailureMessage('InvalidInputError'), + }; + } + if (error instanceof ConfigurationError) { + return { + type: 'ConfigurationError', + code: error.code, + retryable: false, + message: capellaSafeFailureMessage('ConfigurationError'), + }; + } + if (error instanceof InvalidInputError) { + return { + type: 'InvalidInputError', + code: error.code, + retryable: false, + message: capellaSafeFailureMessage('InvalidInputError'), + }; + } + if (error instanceof SastContractError) { + return { + type: 'SastContractError', + code: error.code, + retryable: false, + message: capellaSafeFailureMessage('SastContractError'), + }; + } + if (error instanceof ApplicationFailure) { + const type = error.type ?? 'AgentExecutionError'; + return { + type, + code: capellaFailureCode(error, 'APPLICATION_FAILURE'), + retryable: !error.nonRetryable, + message: capellaSafeFailureMessage(type), + }; + } + // Anything unrecognized is presumed transient; the policy's attempt cap bounds the retries. + return { + type: 'AgentExecutionError', + code: capellaFailureCode(error, 'ACTIVITY_FAILURE'), + retryable: true, + message: capellaSafeFailureMessage('AgentExecutionError'), + }; +} + +function cancellationInCauseChain(error: unknown): CancelledFailure | undefined { + let current = error; + const seen = new Set(); + let depth = 0; + while (current && typeof current === 'object' && !seen.has(current) && depth < 20) { + if (current instanceof CancelledFailure) return current; + seen.add(current); + current = 'cause' in current ? (current as { readonly cause?: unknown }).cause : undefined; + depth += 1; + } + return undefined; +} + +/** + * Treat an error as cancellation only when Temporal's own signal has fired. Provider + * timeouts and aborted requests can look like cancellation but must stay ordinary + * failures, or a timed-out stage would be reported as a cancelled scan. + */ +function activityCancellation(error: unknown, signal: AbortSignal): CancelledFailure | undefined { + if (!signal.aborted) return undefined; + return cancellationInCauseChain(signal.reason) ?? cancellationInCauseChain(error); +} + +async function stageInputFingerprint(input: CapellaStageInput): Promise { + return buildRunInputFingerprint(input, await repositoryIdentity(input.repoPath)); +} + +async function runStageActivity( + stage: CapellaStage, + input: CapellaStageInput, + run: StageRunner, + compact: (value: T) => V, +): Promise> { + const context = Context.current(); + const attempt = context.info.attempt; + const signal = context.cancellationSignal; + const policy = Object.values(CAPELLA_ACTIVITY_POLICIES).find((candidate) => candidate.stage === stage); + const maximumAttempts = policy?.retry.maximumAttempts ?? attempt; + const startedAt = Date.now(); + let heartbeatInterval: NodeJS.Timeout | undefined; + let inputFingerprint: string | undefined; + let completedStageReturned = false; + + try { + // A missing policy row heartbeats too; only an explicit null opts a stage out. + if (policy?.heartbeatTimeoutMs !== null) { + heartbeat({ stage, attempt, elapsedSeconds: 0 }); + heartbeatInterval = setInterval(() => { + heartbeat({ stage, attempt, elapsedSeconds: Math.floor((Date.now() - startedAt) / 1_000) }); + }, HEARTBEAT_INTERVAL_MS); + } + + const initialCancellation = activityCancellation(signal.reason, signal); + if (initialCancellation) throw initialCancellation; + + inputFingerprint = await stageInputFingerprint(input); + const fallbackFailure = stage === 'export' ? (input as CapellaExportActivityInput).fallbackFailure : undefined; + if (fallbackFailure !== undefined) { + await recordRunFailure(input, inputFingerprint, fallbackFailure, true); + } + const executionKey = sha256Parts(context.info.workflowExecution.runId, context.info.activityId).slice(0, 32); + await writeActivityAttemptRecord(input.artifactRoot, { + schemaVersion: USAGE_RECORD_SCHEMA_VERSION, + state: 'activity-attempt', + inputFingerprint, + stage, + executionKey, + attempt, + }); + const executor = new UsageRecordingExecutor(capellaAgentExecutor, input.artifactRoot, { + inputFingerprint, + stage, + executionKey, + attempt, + }); + const repositoryTools = await createCapellaRepositoryTools({ + repositoryRoot: input.repoPath, + deniedPaths: [...input.codePathAvoids, ...CONFINEMENT_ONLY_DENIED_PATHS], + }); + const result = await run({ executor, repositoryTools, signal }); + // Stage runners return only after they publish their artifact and run.json completion. + // Later accounting or logging failures must not overwrite that terminal success, while + // a failed cache verification before this point must still replace stale success state. + completedStageReturned = true; + // A stage that finished while cancellation raced in must not report success; + // the workflow would record a completed stage on a cancelled scan. + const completionCancellation = activityCancellation(signal.reason, signal); + if (completionCancellation) throw completionCancellation; + + const summary = await aggregateStageUsage(input.artifactRoot, inputFingerprint, stage); + // Heal run.json's per-stage figure, written from the successful attempt alone, to the + // ledger aggregate that also counts any failed attempts of this stage. + await recordStageUsageAccounting(input, inputFingerprint, stage, summary); + const compactValue = compact(result.value); + return { + status: 'completed', + durationMs: result.durationMs, + reused: result.reused, + artifact: result.artifact, + value: compactValue, + attempts: attempt, + usage: summary.usage, + usageComplete: !summary.retried && summary.complete, + }; + } catch (error) { + const cancellation = activityCancellation(error, signal); + if (cancellation) { + throw cancellation; + } + + const classified = classifyActivityError(error); + let summary: StageUsageSummary = { usage: ZERO_CAPELLA_USAGE, complete: true, retried: attempt > 1 }; + if (inputFingerprint) { + summary = await aggregateStageUsage(input.artifactRoot, inputFingerprint, stage).catch(() => ({ + usage: ZERO_CAPELLA_USAGE, + complete: false, + retried: true, + })); + const terminal = !classified.retryable || attempt >= maximumAttempts; + // run.json shows 'failed' only for a terminal failure; a live retry keeps + // finalState 'running' so an operator does not read an in-flight recovery as a + // dead scan. Best-effort: failing to record the failure must not replace it. + const fallbackFailure = stage === 'export' ? (input as CapellaExportActivityInput).fallbackFailure : undefined; + if (fallbackFailure !== undefined) { + await recordRunFailure(input, inputFingerprint, fallbackFailure, true, undefined, { + preserveExistingFailure: true, + preserveExistingSuccess: completedStageReturned, + }).catch(() => undefined); + } else { + await recordRunFailure( + input, + inputFingerprint, + { + stage, + code: classified.code, + error: classified.message, + attempt, + retryable: classified.retryable, + }, + terminal, + summary, + { preserveExistingSuccess: completedStageReturned }, + ).catch(() => undefined); + } + } + const stageComplete = !summary.retried && summary.complete; + const details: CapellaActivityFailureDetails & { readonly code: string } = { + stage, + code: classified.code, + attempts: attempt, + usage: summary.usage, + usageComplete: stageComplete, + warnings: stageComplete ? [] : [usageAccountingWarning(stage)], + }; + // The message crossing the Temporal boundary comes from the fixed safe-message + // table; raw provider and filesystem text never enters workflow history. + throw ApplicationFailure.create({ + message: classified.message, + type: classified.type, + nonRetryable: !classified.retryable, + details: [details], + }); + } finally { + if (heartbeatInterval) clearInterval(heartbeatInterval); + } +} + +export async function capellaArchitecture(input: CapellaActivityInput): Promise { + return runStageActivity( + 'architecture', + input, + (runtime) => runArchitectureStage(input, runtime), + (value) => ({ + componentCount: value.componentCount, + ...(value.reduction !== undefined && { reduction: value.reduction }), + }), + ); +} + +export async function capellaThreatModel( + input: CapellaThreatModelActivityInput, +): Promise { + return runStageActivity( + 'threat-model', + input, + (runtime) => runThreatModelStage(input, runtime), + (value) => ({ + intent: value.intent, + }), + ); +} + +export async function capellaPlan(input: CapellaPlanActivityInput): Promise { + return runStageActivity( + 'plan', + input, + (runtime) => runPlanStage(input, runtime), + (value) => ({ + investigationCount: value.investigationCount, + ...(value.reduction !== undefined && { reduction: value.reduction }), + }), + ); +} + +export async function capellaResearch(input: CapellaResearchActivityInput): Promise { + return runStageActivity( + 'research', + input, + (runtime) => runResearchStage(input, runtime), + (value) => ({ + findingCount: value.findings.length, + flaggedFileCount: value.flaggedFiles.length, + dispatchedCount: value.dispatchedCount, + resumedCount: value.resumedCount, + coverage: value.coverage, + ...(value.reduction !== undefined && { reduction: value.reduction }), + }), + ); +} + +export async function capellaDedupe(input: CapellaFindingActivityInput): Promise { + return runStageActivity( + 'dedupe', + input, + (runtime) => runDedupeStage(input, runtime), + (value) => ({ + findingCount: value.findings.length, + duplicateCount: value.duplicateCount, + survivorCount: value.survivorCount, + ...(value.reduction !== undefined && { reduction: value.reduction }), + }), + ); +} + +export async function capellaReview(input: CapellaFindingActivityInput): Promise { + return runStageActivity( + 'review', + input, + (runtime) => runReviewStage(input, runtime), + (value) => ({ + findingCount: value.findings.length, + validCount: value.validCount, + provisionalCount: value.provisionalCount, + falsePositiveCount: value.falsePositiveCount, + ...(value.reduction !== undefined && { reduction: value.reduction }), + }), + ); +} + +export async function capellaCritic(input: CapellaKnowledgeFindingActivityInput): Promise { + return runStageActivity( + 'critic', + input, + (runtime) => runCriticStage(input, runtime), + (value) => ({ + findingCount: value.findings.length, + viableCount: value.viableCount, + ...(value.reduction !== undefined && { reduction: value.reduction }), + }), + ); +} + +export async function capellaConfirm(input: CapellaFindingActivityInput): Promise { + return runStageActivity( + 'confirm', + input, + (runtime) => runConfirmStage(input, runtime), + (value) => ({ + findingCount: value.findings.length, + confirmedCount: value.confirmedCount, + ...(value.reduction !== undefined && { reduction: value.reduction }), + }), + ); +} + +export async function capellaCalibrate( + input: CapellaKnowledgeFindingActivityInput, +): Promise { + return runStageActivity( + 'calibrate', + input, + (runtime) => runCalibrateStage(input, runtime), + (value) => ({ + findingCount: value.findings.length, + calibratedCount: value.calibratedCount, + ...(value.reduction !== undefined && { reduction: value.reduction }), + }), + ); +} + +export async function capellaExport(input: CapellaExportActivityInput): Promise { + return runStageActivity( + 'export', + input, + async (runtime) => { + let repositoryLabel: string; + try { + repositoryLabel = basename(await realpath(input.repoPath)); + } catch { + throw new InvalidInputError('Capella repository root does not exist', 'REPOSITORY_UNAVAILABLE'); + } + const stageInput = { ...input, repositoryLabel }; + return runExportStage(stageInput, runtime.signal); + }, + (value) => ({ + sarif: value.sarif, + findingCount: value.findingCount, + coverage: value.coverage, + warnings: [...value.warnings], + ...(value.reduction !== undefined && { reduction: value.reduction }), + }), + ); +} diff --git a/apps/worker/src/ai/sast/capella/temporal/activity-types.ts b/apps/worker/src/ai/sast/capella/temporal/activity-types.ts new file mode 100644 index 00000000..c0120615 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/temporal/activity-types.ts @@ -0,0 +1,294 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** Workflow-safe contracts shared by the Capella child and its activity registry. */ + +import type { ModelRole } from '../../../model-host.js'; +import type { + AgenticSastArchitectureReduction, + AgenticSastCalibrateReduction, + AgenticSastConfirmReduction, + AgenticSastCriticReduction, + AgenticSastDedupeReduction, + AgenticSastFallbackReduction, + AgenticSastPlanReduction, + AgenticSastResearchReduction, + AgenticSastReviewReduction, + CapellaFallbackStage, + CapellaStage, + CapellaUsage, + SarifRef, +} from '../../types.js'; +import { CAPELLA_NON_RETRYABLE_ERROR_TYPES } from '../error-contract.js'; +import type { + ArchitectureValue, + CalibrateValue, + CapellaArtifactRef, + ConfirmValue, + CriticValue, + DedupeValue, + ExportValue, + PlanValue, + ResearchValue, + ReviewValue, + ThreatModelValue, +} from '../types.js'; + +export interface CapellaWorkflowInput { + readonly repoPath: string; + readonly artifactRoot: string; + readonly workflowLogPath: string; + readonly promptDir: string; + readonly codePathAvoids: readonly string[]; + readonly codePathFocus: readonly string[]; + readonly modelSpec: string; + readonly capellaFormatVersion: string; + readonly promptSetVersion: string; + readonly pipelineTestingMode: boolean; +} + +export interface CapellaActivityInput extends CapellaWorkflowInput { + readonly timeoutMs: number; +} + +export interface CapellaThreatModelActivityInput extends CapellaActivityInput { + readonly architectureArtifact: CapellaArtifactRef; +} + +export interface CapellaPlanActivityInput extends CapellaActivityInput { + readonly architectureArtifact: CapellaArtifactRef; + readonly threatModelArtifact: CapellaArtifactRef; +} + +export interface CapellaResearchActivityInput extends CapellaActivityInput { + readonly architectureArtifact: CapellaArtifactRef; + readonly planArtifact: CapellaArtifactRef; +} + +export interface CapellaFindingActivityInput extends CapellaActivityInput { + readonly findingsArtifact: CapellaArtifactRef; +} + +export interface CapellaKnowledgeFindingActivityInput extends CapellaFindingActivityInput { + readonly architectureArtifact: CapellaArtifactRef; + readonly threatModelArtifact: CapellaArtifactRef; +} + +export type CapellaExportSourceStage = 'research' | 'dedupe' | 'review' | 'critic' | 'confirm' | 'calibrate'; + +export interface CapellaExportActivityInput extends CapellaActivityInput { + readonly findingsArtifact?: CapellaArtifactRef; + readonly findingsStage?: CapellaExportSourceStage; + readonly fallbackReduction?: AgenticSastFallbackReduction; + readonly fallbackFailure?: CapellaFallbackFailure; +} + +export interface CapellaFallbackFailure { + readonly stage: CapellaFallbackStage; + readonly code: string; + readonly error: string; + readonly attempt: number; + readonly retryable: boolean; +} + +export interface CapellaActivityResult { + readonly status: 'completed'; + readonly durationMs: number; + readonly reused: boolean; + readonly artifact: CapellaArtifactRef; + readonly value: T; + readonly attempts: number; + readonly usage: CapellaUsage; + /** True only when the usage ledger accounts for every session and no retry occurred; false means usage is a lower bound. */ + readonly usageComplete: boolean; +} + +export interface CapellaArchitectureActivityValue { + readonly componentCount: ArchitectureValue['componentCount']; + readonly reduction?: AgenticSastArchitectureReduction; +} + +export interface CapellaThreatModelActivityValue { + readonly intent: ThreatModelValue['intent']; +} + +export interface CapellaPlanActivityValue { + readonly investigationCount: PlanValue['investigationCount']; + readonly reduction?: AgenticSastPlanReduction; +} + +export interface CapellaResearchActivityValue { + readonly findingCount: number; + readonly flaggedFileCount: number; + readonly dispatchedCount: ResearchValue['dispatchedCount']; + readonly resumedCount: ResearchValue['resumedCount']; + readonly coverage: ResearchValue['coverage']; + // Present only when coverage is 'reduced'. Counts only — no assigned/missing file path crosses + // this boundary, so nothing model-authored or path-bearing can reach a public surface. + readonly reduction?: AgenticSastResearchReduction; +} + +export interface CapellaDedupeActivityValue { + readonly findingCount: number; + readonly duplicateCount: DedupeValue['duplicateCount']; + readonly survivorCount: DedupeValue['survivorCount']; + readonly reduction?: AgenticSastDedupeReduction; +} + +export interface CapellaReviewActivityValue { + readonly findingCount: number; + readonly validCount: ReviewValue['validCount']; + readonly provisionalCount: ReviewValue['provisionalCount']; + readonly falsePositiveCount: ReviewValue['falsePositiveCount']; + readonly reduction?: AgenticSastReviewReduction; +} + +export interface CapellaCriticActivityValue { + readonly findingCount: number; + readonly viableCount: CriticValue['viableCount']; + readonly reduction?: AgenticSastCriticReduction; +} + +export interface CapellaConfirmActivityValue { + readonly findingCount: number; + readonly confirmedCount: ConfirmValue['confirmedCount']; + readonly reduction?: AgenticSastConfirmReduction; +} + +export interface CapellaCalibrateActivityValue { + readonly findingCount: number; + readonly calibratedCount: CalibrateValue['calibratedCount']; + readonly reduction?: AgenticSastCalibrateReduction; +} + +export interface CapellaExportActivityValue { + readonly sarif: SarifRef; + readonly findingCount: ExportValue['findingCount']; + readonly coverage: ExportValue['coverage']; + readonly warnings: readonly string[]; + readonly reduction?: ExportValue['reduction']; +} + +export type CapellaArchitectureActivityResult = CapellaActivityResult; +export type CapellaThreatModelActivityResult = CapellaActivityResult; +export type CapellaPlanActivityResult = CapellaActivityResult; +export type CapellaResearchActivityResult = CapellaActivityResult; +export type CapellaDedupeActivityResult = CapellaActivityResult; +export type CapellaReviewActivityResult = CapellaActivityResult; +export type CapellaCriticActivityResult = CapellaActivityResult; +export type CapellaConfirmActivityResult = CapellaActivityResult; +export type CapellaCalibrateActivityResult = CapellaActivityResult; +export type CapellaExportActivityResult = CapellaActivityResult; + +/** + * Bounded failure payload carried as the first `details` entry of the activity's + * ApplicationFailure. The child workflow revalidates the shape before trusting it, + * so a field added here is ignored until that validator learns it. + */ +export interface CapellaActivityFailureDetails { + readonly stage: CapellaStage; + /** Bounded internal or provider-category machine code identifying the classified failure. */ + readonly code: string; + readonly attempts: number; + readonly usage: CapellaUsage; + readonly usageComplete: boolean; + /** Reasons the stage's usage accounting could not be trusted; empty when the ledger reconciled. */ + readonly warnings: readonly string[]; +} + +export interface CapellaActivityRegistry { + readonly capellaArchitecture: (input: CapellaActivityInput) => Promise; + readonly capellaThreatModel: (input: CapellaThreatModelActivityInput) => Promise; + readonly capellaPlan: (input: CapellaPlanActivityInput) => Promise; + readonly capellaResearch: (input: CapellaResearchActivityInput) => Promise; + readonly capellaDedupe: (input: CapellaFindingActivityInput) => Promise; + readonly capellaReview: (input: CapellaFindingActivityInput) => Promise; + readonly capellaCritic: (input: CapellaKnowledgeFindingActivityInput) => Promise; + readonly capellaConfirm: (input: CapellaFindingActivityInput) => Promise; + readonly capellaCalibrate: (input: CapellaKnowledgeFindingActivityInput) => Promise; + readonly capellaExport: (input: CapellaExportActivityInput) => Promise; +} + +/** + * The ten Capella names inside the worker's frozen activity registry. Worker startup + * asserts the registered set against this list, and running workflows refer to + * activities by these strings, so a rename breaks resume of in-flight scans. + */ +export const CAPELLA_ACTIVITY_NAMES = Object.freeze([ + 'capellaArchitecture', + 'capellaThreatModel', + 'capellaPlan', + 'capellaResearch', + 'capellaDedupe', + 'capellaReview', + 'capellaCritic', + 'capellaConfirm', + 'capellaCalibrate', + 'capellaExport', +] as const satisfies readonly (keyof CapellaActivityRegistry)[]); + +export { CAPELLA_NON_RETRYABLE_ERROR_TYPES } from '../error-contract.js'; + +export interface CapellaActivityPolicy { + readonly stage: CapellaStage; + readonly startToCloseTimeoutMs: number; + readonly scheduleToCloseTimeoutMs: number; + /** Null disables heartbeating entirely, including the activity wrapper's background heartbeat loop. */ + readonly heartbeatTimeoutMs: number | null; + readonly retry: { + readonly initialIntervalMs: number; + readonly maximumIntervalMs: number; + readonly backoffCoefficient: number; + readonly maximumAttempts: number; + readonly nonRetryableErrorTypes: readonly string[]; + }; + readonly role: ModelRole | 'small + medium' | 'none'; +} + +const MINUTE_MS = 60 * 1_000; +const HOUR_MS = 60 * MINUTE_MS; + +function policy( + stage: CapellaStage, + startToCloseTimeoutMs: number, + scheduleToCloseTimeoutMs: number, + heartbeatTimeoutMs: number | null, + maximumAttempts: number, + role: ModelRole | 'small + medium' | 'none', +): Readonly { + return Object.freeze({ + stage, + startToCloseTimeoutMs, + scheduleToCloseTimeoutMs, + heartbeatTimeoutMs, + retry: Object.freeze({ + initialIntervalMs: MINUTE_MS, + maximumIntervalMs: 5 * MINUTE_MS, + backoffCoefficient: 2, + maximumAttempts, + nonRetryableErrorTypes: CAPELLA_NON_RETRYABLE_ERROR_TYPES, + }), + role, + }); +} + +export const CAPELLA_ACTIVITY_POLICIES = Object.freeze({ + capellaArchitecture: policy('architecture', 60 * MINUTE_MS, 60 * MINUTE_MS, 5 * MINUTE_MS, 3, 'large'), + capellaThreatModel: policy('threat-model', 30 * MINUTE_MS, 30 * MINUTE_MS, 5 * MINUTE_MS, 2, 'medium'), + capellaPlan: policy('plan', 30 * MINUTE_MS, 90 * MINUTE_MS, 5 * MINUTE_MS, 2, 'medium'), + capellaResearch: policy('research', 3 * HOUR_MS, 4.5 * HOUR_MS, 5 * MINUTE_MS, 2, 'small + medium'), + capellaDedupe: policy('dedupe', 30 * MINUTE_MS, 45 * MINUTE_MS, 5 * MINUTE_MS, 2, 'small'), + capellaReview: policy('review', 2 * HOUR_MS, 2 * HOUR_MS, 5 * MINUTE_MS, 2, 'medium'), + capellaCritic: policy('critic', 60 * MINUTE_MS, 60 * MINUTE_MS, 5 * MINUTE_MS, 2, 'medium'), + capellaConfirm: policy('confirm', 60 * MINUTE_MS, 60 * MINUTE_MS, 5 * MINUTE_MS, 2, 'medium'), + capellaCalibrate: policy('calibrate', 45 * MINUTE_MS, 45 * MINUTE_MS, 5 * MINUTE_MS, 2, 'small'), + // Export runs no model but writes final artifacts; a heartbeat keeps it cancellable mid-run + // instead of letting a cancelled scan keep materializing SARIF for up to its start-to-close. + capellaExport: policy('export', 5 * MINUTE_MS, 10 * MINUTE_MS, MINUTE_MS, 2, 'none'), +} as const satisfies Readonly>>); + +/** Bounds the whole child pipeline, including every stage's retries and backoff. */ +export const CAPELLA_CHILD_WORKFLOW_TIMEOUT_MS = 15 * HOUR_MS; diff --git a/apps/worker/src/ai/sast/capella/temporal/registry.ts b/apps/worker/src/ai/sast/capella/temporal/registry.ts new file mode 100644 index 00000000..f0e7f789 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/temporal/registry.ts @@ -0,0 +1,67 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { + capellaArchitecture, + capellaCalibrate, + capellaConfirm, + capellaCritic, + capellaDedupe, + capellaExport, + capellaPlan, + capellaResearch, + capellaReview, + capellaThreatModel, +} from './activities.js'; +import { CAPELLA_ACTIVITY_NAMES, type CapellaActivityRegistry } from './activity-types.js'; + +const registry = { + capellaArchitecture, + capellaThreatModel, + capellaPlan, + capellaResearch, + capellaDedupe, + capellaReview, + capellaCritic, + capellaConfirm, + capellaCalibrate, + capellaExport, +} satisfies CapellaActivityRegistry; + +// The satisfies clause proves the shape at compile time; this runtime check catches +// the remaining drift risk, the name list and the object literal edited apart, and +// stops the worker at module load instead of registering a wrong surface. +const registeredNames = Object.keys(registry).sort(); +const expectedNames = [...CAPELLA_ACTIVITY_NAMES].sort(); +if ( + registeredNames.length !== expectedNames.length || + registeredNames.some((name, index) => name !== expectedNames[index]) +) { + throw new Error('Capella activity registry does not match its frozen ten-name contract'); +} + +/** Frozen production registry containing the ten Capella activity wrappers. */ +export const capellaActivities: Readonly = Object.freeze(registry); + +type UnionToIntersection = (T extends unknown ? (value: T) => void : never) extends (value: infer I) => void + ? I + : never; + +/** Merge explicit activity registries and fail before worker startup on any collision. */ +export function mergeActivityRegistries( + ...registries: T +): Readonly> { + const merged: Record = Object.create(null) as Record; + for (const activityRegistry of registries) { + for (const [name, implementation] of Object.entries(activityRegistry)) { + if (Object.hasOwn(merged, name)) { + throw new Error(`Duplicate Temporal activity registration: ${name}`); + } + merged[name] = implementation; + } + } + return Object.freeze(merged) as Readonly>; +} diff --git a/apps/worker/src/ai/sast/capella/temporal/workflow.ts b/apps/worker/src/ai/sast/capella/temporal/workflow.ts new file mode 100644 index 00000000..e50cdfd7 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/temporal/workflow.ts @@ -0,0 +1,481 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * Workflow-safe Capella child. + * + * Everything reachable as a value import from this module must remain safe for + * the Temporal workflow isolate. Activity implementations are imported as types. + */ + +import type { ActivityOptions, ChildWorkflowOptions } from '@temporalio/workflow'; +import { + ActivityCancellationType, + ApplicationFailure, + ChildWorkflowCancellationType, + isCancellation, + proxyActivities, +} from '@temporalio/workflow'; +import { isProviderFailureCategory } from '../../../../types/errors.js'; +import type { + AgenticSastFallbackReduction, + AgenticSastReduction, + CapellaRecoveredFailure, + CapellaRunResult, + CapellaStage, + CapellaUsage, +} from '../../types.js'; +import { capellaSafeFailureMessage } from '../safe-failures.js'; +import { usageAccountingWarning } from '../types.js'; +import { + CAPELLA_ACTIVITY_POLICIES, + CAPELLA_CHILD_WORKFLOW_TIMEOUT_MS, + type CapellaActivityFailureDetails, + type CapellaActivityInput, + type CapellaActivityPolicy, + type CapellaActivityRegistry, + type CapellaActivityResult, + type CapellaExportActivityInput, + type CapellaExportActivityResult, + type CapellaExportSourceStage, + type CapellaFallbackFailure, + type CapellaFindingActivityInput, + type CapellaKnowledgeFindingActivityInput, + type CapellaPlanActivityInput, + type CapellaResearchActivityInput, + type CapellaThreatModelActivityInput, + type CapellaWorkflowInput, +} from './activity-types.js'; + +const ZERO_USAGE: CapellaUsage = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + costUsd: 0, + turns: 0, +}; + +export const CAPELLA_CHILD_WORKFLOW_OPTIONS = Object.freeze({ + workflowExecutionTimeout: CAPELLA_CHILD_WORKFLOW_TIMEOUT_MS, + cancellationType: ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED, +} as const satisfies Pick); + +function activityOptions(policy: CapellaActivityPolicy): ActivityOptions { + return { + startToCloseTimeout: policy.startToCloseTimeoutMs, + scheduleToCloseTimeout: policy.scheduleToCloseTimeoutMs, + ...(policy.heartbeatTimeoutMs === null ? {} : { heartbeatTimeout: policy.heartbeatTimeoutMs }), + retry: { + initialInterval: policy.retry.initialIntervalMs, + maximumInterval: policy.retry.maximumIntervalMs, + backoffCoefficient: policy.retry.backoffCoefficient, + maximumAttempts: policy.retry.maximumAttempts, + nonRetryableErrorTypes: [...policy.retry.nonRetryableErrorTypes], + }, + cancellationType: ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, + }; +} + +// One proxy per activity: options bind at proxy creation, and every stage carries +// its own timeout and retry policy. +const architectureActivities = proxyActivities>( + activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaArchitecture), +); +const threatModelActivities = proxyActivities>( + activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaThreatModel), +); +const planActivities = proxyActivities>( + activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaPlan), +); +const researchActivities = proxyActivities>( + activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaResearch), +); +const dedupeActivities = proxyActivities>( + activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaDedupe), +); +const reviewActivities = proxyActivities>( + activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaReview), +); +const criticActivities = proxyActivities>( + activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaCritic), +); +const confirmActivities = proxyActivities>( + activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaConfirm), +); +const calibrateActivities = proxyActivities>( + activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaCalibrate), +); +const exportActivities = proxyActivities>( + activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaExport), +); + +// addUsage and isUsage are duplicated from the activity side on purpose: this module +// must stay importable inside the workflow isolate, which rules out sharing a module +// that reaches Node APIs. Keep the twins in sync. +function addUsage(left: CapellaUsage, right: CapellaUsage): CapellaUsage { + return { + inputTokens: left.inputTokens + right.inputTokens, + outputTokens: left.outputTokens + right.outputTokens, + cacheReadTokens: left.cacheReadTokens + right.cacheReadTokens, + cacheWriteTokens: left.cacheWriteTokens + right.cacheWriteTokens, + costUsd: left.costUsd + right.costUsd, + turns: left.turns + right.turns, + }; +} + +function isUsage(value: unknown): value is CapellaUsage { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + const integerFields = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'turns']; + return ( + integerFields.every((field) => Number.isSafeInteger(record[field]) && Number(record[field]) >= 0) && + typeof record.costUsd === 'number' && + Number.isFinite(record.costUsd) && + record.costUsd >= 0 + ); +} + +const FAILURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; + +function isFailureCode(value: unknown): value is string { + return typeof value === 'string' && (FAILURE_CODE_PATTERN.test(value) || isProviderFailureCategory(value)); +} + +// Details cross the wire through the payload converter; revalidate the shape rather +// than trust the activity's typing. +function isActivityFailureDetails(value: unknown): value is CapellaActivityFailureDetails { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return ( + typeof record.stage === 'string' && + isFailureCode(record.code) && + Number.isSafeInteger(record.attempts) && + Number(record.attempts) >= 1 && + typeof record.usageComplete === 'boolean' && + Array.isArray(record.warnings) && + record.warnings.every((warning) => typeof warning === 'string') && + isUsage(record.usage) + ); +} + +function applicationFailure(error: unknown): ApplicationFailure | undefined { + let current = error; + const seen = new Set(); + while (current && typeof current === 'object' && !seen.has(current)) { + if (current instanceof ApplicationFailure) return current; + seen.add(current); + current = 'cause' in current ? (current as { readonly cause?: unknown }).cause : undefined; + } + return undefined; +} + +function hasCancellationInCauseChain(error: unknown): boolean { + let current = error; + const seen = new Set(); + let depth = 0; + while (current instanceof Error && !seen.has(current) && depth < 20) { + if (isCancellation(current)) return true; + seen.add(current); + current = current.cause; + depth += 1; + } + return false; +} + +function failureDetails(error: unknown): CapellaActivityFailureDetails | undefined { + const details = applicationFailure(error)?.details; + const first = details?.[0]; + return isActivityFailureDetails(first) ? first : undefined; +} + +function baseInput(input: CapellaWorkflowInput, policy: CapellaActivityPolicy): CapellaActivityInput { + return { ...input, timeoutMs: policy.startToCloseTimeoutMs }; +} + +interface WorkflowAccumulator { + usage: CapellaUsage; + usageComplete: boolean; + readonly completedStages: CapellaStage[]; + readonly warnings: string[]; + // Reduced-coverage summaries in the order stages produce them (research before export). + readonly reductions: AgenticSastReduction[]; +} + +function acceptStage(accumulator: WorkflowAccumulator, stage: CapellaStage, result: CapellaActivityResult): void { + accumulator.usage = addUsage(accumulator.usage, result.usage); + accumulator.usageComplete &&= result.usageComplete; + // A stage that retried or whose ledger was incomplete drives the same warning run.json records + // in recordStageUsageAccounting, so a retried-then-succeeded stage names its reason in every + // downstream ledger too. succeededResult and the failed result both dedupe and sort warnings. + if (!result.usageComplete) { + accumulator.warnings.push(usageAccountingWarning(stage)); + } + accumulator.completedStages.push(stage); + if (result.value && typeof result.value === 'object' && 'reduction' in result.value) { + const reduction = (result.value as { readonly reduction?: AgenticSastReduction }).reduction; + if (reduction !== undefined) { + const existingIndex = accumulator.reductions.findIndex((entry) => entry.stage === reduction.stage); + if (existingIndex >= 0) accumulator.reductions[existingIndex] = reduction; + else accumulator.reductions.push(reduction); + } + } +} + +function exportInput( + input: CapellaWorkflowInput, + findingsArtifact?: CapellaExportActivityInput['findingsArtifact'], + findingsStage?: CapellaExportSourceStage, + fallbackReduction?: AgenticSastFallbackReduction, + fallbackFailure?: CapellaFallbackFailure, +): CapellaExportActivityInput { + return { + ...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaExport), + ...(findingsArtifact && findingsStage ? { findingsArtifact, findingsStage } : {}), + ...(fallbackReduction !== undefined && { fallbackReduction }), + ...(fallbackFailure !== undefined && { fallbackFailure }), + }; +} + +function succeededResult( + startedAt: number, + accumulator: WorkflowAccumulator, + result: CapellaExportActivityResult, + recoveredFailure?: CapellaRecoveredFailure, +): CapellaRunResult { + accumulator.warnings.push(...result.value.warnings); + const reductions = [...accumulator.reductions]; + return { + status: 'succeeded', + sarif: result.value.sarif, + findingCount: result.value.findingCount, + // Any reduction (research or export) means the run's static-analysis coverage was reduced. + coverage: reductions.length > 0 ? 'reduced' : result.value.coverage, + durationMs: Date.now() - startedAt, + usage: accumulator.usage, + usageComplete: accumulator.usageComplete, + warnings: [...new Set(accumulator.warnings)].sort(), + ...(reductions.length > 0 && { reductions }), + ...(recoveredFailure !== undefined && { recoveredFailure }), + }; +} + +function acceptFailureDetails( + accumulator: WorkflowAccumulator, + error: unknown, +): CapellaActivityFailureDetails | undefined { + const details = failureDetails(error); + if (details) { + accumulator.usage = addUsage(accumulator.usage, details.usage); + accumulator.usageComplete &&= details.usageComplete; + accumulator.warnings.push(...details.warnings); + } else { + accumulator.usageComplete = false; + } + return details; +} + +function failedResult( + startedAt: number, + accumulator: WorkflowAccumulator, + stage: CapellaStage, + error: string, + errorCode?: string, +): CapellaRunResult { + return { + status: 'failed', + failedStage: stage, + error, + ...(errorCode !== undefined && { errorCode }), + durationMs: Date.now() - startedAt, + usage: accumulator.usage, + usageComplete: accumulator.usageComplete, + completedStages: [...accumulator.completedStages], + warnings: [...new Set(accumulator.warnings)].sort(), + }; +} + +/** Run the isolated ten-stage Capella pipeline and return its bounded result. */ +export async function capellaWorkflow(input: CapellaWorkflowInput): Promise { + const startedAt = Date.now(); + const accumulator: WorkflowAccumulator = { + usage: ZERO_USAGE, + usageComplete: true, + completedStages: [], + warnings: [], + reductions: [], + }; + let currentStage: CapellaStage = 'architecture'; + let lastGoodFindings: + | { + readonly artifact: CapellaFindingActivityInput['findingsArtifact']; + readonly stage: CapellaExportSourceStage; + readonly findingCount: number; + } + | undefined; + + try { + const architecture = await architectureActivities.capellaArchitecture( + baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaArchitecture), + ); + acceptStage(accumulator, 'architecture', architecture); + + currentStage = 'threat-model'; + const threatModelInput: CapellaThreatModelActivityInput = { + ...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaThreatModel), + architectureArtifact: architecture.artifact, + }; + const threatModel = await threatModelActivities.capellaThreatModel(threatModelInput); + acceptStage(accumulator, 'threat-model', threatModel); + + currentStage = 'plan'; + const planInput: CapellaPlanActivityInput = { + ...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaPlan), + architectureArtifact: architecture.artifact, + threatModelArtifact: threatModel.artifact, + }; + const plan = await planActivities.capellaPlan(planInput); + acceptStage(accumulator, 'plan', plan); + + if (plan.value.investigationCount === 0) { + // Nothing to research: still run export so the scan always ends with a valid, + // empty SARIF artifact rather than an absent one. + currentStage = 'export'; + const exported = await exportActivities.capellaExport(exportInput(input)); + acceptStage(accumulator, 'export', exported); + return succeededResult(startedAt, accumulator, exported); + } + + currentStage = 'research'; + const researchInput: CapellaResearchActivityInput = { + ...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaResearch), + architectureArtifact: architecture.artifact, + planArtifact: plan.artifact, + }; + const research = await researchActivities.capellaResearch(researchInput); + acceptStage(accumulator, 'research', research); + lastGoodFindings = { artifact: research.artifact, stage: 'research', findingCount: research.value.findingCount }; + + if (research.value.findingCount === 0) { + currentStage = 'export'; + const exported = await exportActivities.capellaExport(exportInput(input)); + acceptStage(accumulator, 'export', exported); + return succeededResult(startedAt, accumulator, exported); + } + + currentStage = 'dedupe'; + const dedupeInput: CapellaFindingActivityInput = { + ...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaDedupe), + findingsArtifact: research.artifact, + }; + const dedupe = await dedupeActivities.capellaDedupe(dedupeInput); + acceptStage(accumulator, 'dedupe', dedupe); + lastGoodFindings = { artifact: dedupe.artifact, stage: 'dedupe', findingCount: dedupe.value.findingCount }; + + currentStage = 'review'; + const reviewInput: CapellaFindingActivityInput = { + ...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaReview), + findingsArtifact: dedupe.artifact, + }; + const review = await reviewActivities.capellaReview(reviewInput); + acceptStage(accumulator, 'review', review); + lastGoodFindings = { artifact: review.artifact, stage: 'review', findingCount: review.value.findingCount }; + + let exportArtifact = review.artifact; + let exportStage: CapellaExportSourceStage = 'review'; + const reviewedSurvivors = review.value.validCount + review.value.provisionalCount; + if (reviewedSurvivors > 0) { + currentStage = 'critic'; + const criticInput: CapellaKnowledgeFindingActivityInput = { + ...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaCritic), + findingsArtifact: review.artifact, + architectureArtifact: architecture.artifact, + threatModelArtifact: threatModel.artifact, + }; + const critic = await criticActivities.capellaCritic(criticInput); + acceptStage(accumulator, 'critic', critic); + lastGoodFindings = { artifact: critic.artifact, stage: 'critic', findingCount: critic.value.findingCount }; + + currentStage = 'confirm'; + const confirmInput: CapellaFindingActivityInput = { + ...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaConfirm), + findingsArtifact: critic.artifact, + }; + const confirm = await confirmActivities.capellaConfirm(confirmInput); + acceptStage(accumulator, 'confirm', confirm); + lastGoodFindings = { artifact: confirm.artifact, stage: 'confirm', findingCount: confirm.value.findingCount }; + + currentStage = 'calibrate'; + const calibrateInput: CapellaKnowledgeFindingActivityInput = { + ...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaCalibrate), + findingsArtifact: confirm.artifact, + architectureArtifact: architecture.artifact, + threatModelArtifact: threatModel.artifact, + }; + const calibrate = await calibrateActivities.capellaCalibrate(calibrateInput); + acceptStage(accumulator, 'calibrate', calibrate); + lastGoodFindings = { + artifact: calibrate.artifact, + stage: 'calibrate', + findingCount: calibrate.value.findingCount, + }; + exportArtifact = calibrate.artifact; + exportStage = 'calibrate'; + } + + currentStage = 'export'; + const exported = await exportActivities.capellaExport(exportInput(input, exportArtifact, exportStage)); + acceptStage(accumulator, 'export', exported); + return succeededResult(startedAt, accumulator, exported); + } catch (error) { + // Cancellation must escape: absorbing it into a failed result would make a + // cancelled scan look like an accepted Capella failure. Everything else becomes a + // bounded failed result so the parent can continue the pentest without Capella + // findings. + if (hasCancellationInCauseChain(error)) throw error; + + const failedStage = currentStage; + const details = acceptFailureDetails(accumulator, error); + const safeError = capellaSafeFailureMessage(applicationFailure(error)?.type); + if (failedStage === 'export') { + return failedResult(startedAt, accumulator, failedStage, safeError, details?.code); + } + + const completedBeforeFallback = [...accumulator.completedStages]; + const fallbackReduction: AgenticSastFallbackReduction = { + stage: failedStage, + reason: 'failed_stage_fallback', + fallbackFindingCount: lastGoodFindings?.findingCount ?? 0, + }; + const failedApplication = applicationFailure(error); + const fallbackFailure: CapellaFallbackFailure = { + stage: failedStage, + code: details?.code ?? 'ACTIVITY_FAILURE', + error: safeError, + attempt: details?.attempts ?? 1, + retryable: failedApplication === undefined || !failedApplication.nonRetryable, + }; + accumulator.reductions.push(fallbackReduction); + + try { + const fallbackExport = await exportActivities.capellaExport( + exportInput(input, lastGoodFindings?.artifact, lastGoodFindings?.stage, fallbackReduction, fallbackFailure), + ); + acceptStage(accumulator, 'export', fallbackExport); + const recoveredFailure: CapellaRecoveredFailure = { + failedStage, + error: safeError, + ...(details !== undefined && { errorCode: details.code }), + completedStages: completedBeforeFallback, + }; + return succeededResult(startedAt, accumulator, fallbackExport, recoveredFailure); + } catch (fallbackError) { + if (hasCancellationInCauseChain(fallbackError)) throw fallbackError; + acceptFailureDetails(accumulator, fallbackError); + return failedResult(startedAt, accumulator, failedStage, safeError, details?.code); + } + } +} diff --git a/apps/worker/src/ai/sast/capella/tools/confinement.ts b/apps/worker/src/ai/sast/capella/tools/confinement.ts new file mode 100644 index 00000000..30e63cbf --- /dev/null +++ b/apps/worker/src/ai/sast/capella/tools/confinement.ts @@ -0,0 +1,511 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { type Dir, type Dirent, constants as fsConstants, type Stats } from 'node:fs'; +import { type FileHandle, lstat, open, opendir, realpath } from 'node:fs/promises'; +import path from 'node:path'; + +const DEFAULT_OPERATION_TIMEOUT_MS = 2_000; +const DEFAULT_MAX_DEPTH = 32; +const DEFAULT_MAX_ENTRIES = 10_000; +const DEFAULT_MAX_FILE_BYTES = 1_048_576; +const MAX_OPERATION_TIMEOUT_MS = 60_000; +const MAX_ENUMERATION_DEPTH = 64; +const MAX_ENUMERATION_ENTRIES = 100_000; +const MAX_CONFIGURED_FILE_BYTES = 8 * 1_048_576; +const MAX_DENY_RULES = 1_000; +const MAX_DENY_LENGTH = 1_024; + +/** Stable reasons returned by confined tools without disclosing host paths. */ +export type ConfinementErrorCode = + | 'ABORTED' + | 'DENIED' + | 'ESCAPE' + | 'INVALID_PATH' + | 'NOT_FOUND' + | 'NOT_REGULAR_FILE' + | 'NOT_DIRECTORY' + | 'RACE_DETECTED' + | 'TOO_LARGE' + | 'TOO_MANY_ENTRIES' + | 'TOO_DEEP' + | 'TIMED_OUT'; + +/** A bounded confinement failure whose message never contains a requested or host path. */ +export class ConfinementError extends Error { + override readonly name = 'ConfinementError'; + + constructor( + readonly code: ConfinementErrorCode, + message: string, + ) { + super(message); + } +} + +export interface RepositoryConfinementOptions { + readonly repositoryRoot: string; + readonly deniedPaths?: readonly string[]; + readonly operationTimeoutMs?: number; + readonly maxDepth?: number; + readonly maxEntries?: number; + readonly maxFileBytes?: number; +} + +export interface ConfinedFile { + readonly bytes: Buffer; + readonly path: string; + readonly truncated: boolean; +} + +export interface ConfinedEntry { + readonly absolutePath: string; + readonly path: string; + readonly dirent: Dirent; +} + +interface CompiledDeny { + readonly regex: RegExp; +} + +export interface OperationBudget { + readonly deadline: number; + readonly signal?: AbortSignal; +} + +function confinementError(code: ConfinementErrorCode): ConfinementError { + switch (code) { + case 'ABORTED': + return new ConfinementError(code, 'Repository operation cancelled.'); + case 'DENIED': + return new ConfinementError(code, 'Repository path is denied by scan policy.'); + case 'ESCAPE': + return new ConfinementError(code, 'Repository path escapes the configured root.'); + case 'INVALID_PATH': + return new ConfinementError(code, 'Repository path is invalid.'); + case 'NOT_FOUND': + return new ConfinementError(code, 'Repository path does not exist.'); + case 'NOT_REGULAR_FILE': + return new ConfinementError(code, 'Repository path is not a regular file.'); + case 'NOT_DIRECTORY': + return new ConfinementError(code, 'Repository search root is not a directory.'); + case 'RACE_DETECTED': + return new ConfinementError(code, 'Repository path changed during access.'); + case 'TOO_LARGE': + return new ConfinementError(code, 'Repository file exceeds the bounded read limit.'); + case 'TOO_MANY_ENTRIES': + return new ConfinementError(code, 'Repository enumeration exceeded its entry limit.'); + case 'TOO_DEEP': + return new ConfinementError(code, 'Repository enumeration exceeded its depth limit.'); + case 'TIMED_OUT': + return new ConfinementError(code, 'Repository operation exceeded its time limit.'); + } +} + +function toPosix(value: string): string { + return value.split(path.sep).join('/'); +} + +/** Lexical containment only; callers pair it with realpath to defeat symlinks. */ +function isWithin(root: string, candidate: string): boolean { + const relativePath = path.relative(root, candidate); + if (relativePath === '') return true; + if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) return false; + return !path.isAbsolute(relativePath); +} + +function escapeRegex(character: string): string { + return /[\\^$+?.()|{}[\]]/u.test(character) ? `\\${character}` : character; +} + +function globToRegexSource(pattern: string): string { + let source = ''; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + if (character === '*') { + if (pattern[index + 1] === '*') { + while (pattern[index + 1] === '*') index += 1; + if (pattern[index + 1] === '/') { + index += 1; + source += '(?:.*/)?'; + } else { + source += '.*'; + } + } else { + source += '[^/]*'; + } + } else if (character === '?') { + source += '[^/]'; + } else { + source += escapeRegex(character ?? ''); + } + } + return source; +} + +function compileDeny(rawValue: string, realRoot: string): CompiledDeny | undefined { + const trimmed = rawValue.trim(); + if (!trimmed) return undefined; + if (trimmed.length > MAX_DENY_LENGTH || trimmed.includes('\0') || trimmed.includes('\\')) { + throw confinementError('INVALID_PATH'); + } + + let normalized = trimmed + .replace(/^\.\//u, '') + .replace(/\/{2,}/gu, '/') + .replace(/\/$/u, ''); + if (path.isAbsolute(normalized)) { + const relativePath = path.relative(realRoot, path.resolve(normalized)); + // An absolute deny that resolves outside the repository root cannot match + // anything inside the jail, so it is inert and dropped rather than rejected. + if (!isWithin(realRoot, path.resolve(normalized))) return undefined; + normalized = toPosix(relativePath); + } + + const segments = normalized.split('/'); + if (segments.some((segment) => segment === '..' || segment === '')) throw confinementError('INVALID_PATH'); + + const containsGlob = normalized.includes('*') || normalized.includes('?'); + if (containsGlob) { + const source = globToRegexSource(normalized); + if (normalized.endsWith('/**')) { + const directorySource = globToRegexSource(normalized.slice(0, -3)); + return { regex: new RegExp(`^(?:${directorySource}|${source})$`, 'u') }; + } + return { regex: new RegExp(`^(?:${source})$`, 'u') }; + } + + const escaped = normalized + .split('') + .map((character) => escapeRegex(character)) + .join(''); + const prefix = normalized.includes('/') ? '' : '(?:.*/)?'; + return { regex: new RegExp(`^${prefix}${escaped}(?:/.*)?$`, 'u') }; +} + +/** Out-of-range options are rejected outright; clamping would silently weaken a configured bound. */ +function boundedOption(value: number | undefined, fallback: number, maximum: number): number { + if (value === undefined) return fallback; + if (!Number.isInteger(value) || value < 1 || value > maximum) throw confinementError('INVALID_PATH'); + return value; +} + +function sameIdentity(left: Stats, right: Stats): boolean { + return left.dev === right.dev && left.ino === right.ino && left.isFile() && right.isFile(); +} + +function sameEntryIdentity(left: Stats, right: Stats): boolean { + const sameType = (left.isFile() && right.isFile()) || (left.isDirectory() && right.isDirectory()); + return left.dev === right.dev && left.ino === right.ino && sameType; +} + +async function lstatForConfinement(target: string, code: ConfinementErrorCode): Promise { + try { + return await lstat(target); + } catch { + throw confinementError(code); + } +} + +async function realpathForConfinement(target: string, code: ConfinementErrorCode): Promise { + try { + return await realpath(target); + } catch { + throw confinementError(code); + } +} + +function validateRequestedPath(requestedPath: string, allowRoot: boolean): string { + if (requestedPath.includes('\0') || requestedPath.includes('\\') || path.isAbsolute(requestedPath)) { + throw confinementError('INVALID_PATH'); + } + + const normalized = requestedPath || '.'; + const segments = normalized.split('/'); + if (segments.some((segment) => segment === '..' || segment === '')) { + throw confinementError('INVALID_PATH'); + } + if (!allowRoot && segments.every((segment) => segment === '.')) { + throw confinementError('INVALID_PATH'); + } + return normalized; +} + +/** Realpath-backed, deny-aware repository view shared by every Capella read tool. */ +export class RepositoryConfinement { + private constructor( + readonly root: string, + private readonly denies: readonly CompiledDeny[], + private readonly operationTimeoutMs: number, + private readonly maxDepth: number, + private readonly maxEntries: number, + private readonly maxFileBytes: number, + ) {} + + static async create(options: RepositoryConfinementOptions): Promise { + if ((options.deniedPaths?.length ?? 0) > MAX_DENY_RULES) throw confinementError('INVALID_PATH'); + + let realRoot: string; + try { + realRoot = await realpath(options.repositoryRoot); + } catch { + throw confinementError('NOT_FOUND'); + } + + const rootStats = await lstatForConfinement(realRoot, 'NOT_FOUND'); + if (!rootStats.isDirectory()) throw confinementError('NOT_DIRECTORY'); + + const denies = (options.deniedPaths ?? []) + .map((value) => compileDeny(value, realRoot)) + .filter((value): value is CompiledDeny => value !== undefined); + + return new RepositoryConfinement( + realRoot, + denies, + boundedOption(options.operationTimeoutMs, DEFAULT_OPERATION_TIMEOUT_MS, MAX_OPERATION_TIMEOUT_MS), + boundedOption(options.maxDepth, DEFAULT_MAX_DEPTH, MAX_ENUMERATION_DEPTH), + boundedOption(options.maxEntries, DEFAULT_MAX_ENTRIES, MAX_ENUMERATION_ENTRIES), + boundedOption(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, MAX_CONFIGURED_FILE_BYTES), + ); + } + + relativePath(absolutePath: string): string { + if (!isWithin(this.root, absolutePath)) throw confinementError('ESCAPE'); + const relativePath = toPosix(path.relative(this.root, absolutePath)); + return relativePath || '.'; + } + + isDenied(relativePath: string): boolean { + const normalized = relativePath.replace(/^\.\//u, ''); + return this.denies.some(({ regex }) => regex.test(normalized)); + } + + createBudget(signal?: AbortSignal): OperationBudget { + return { + deadline: Date.now() + this.operationTimeoutMs, + ...(signal && { signal }), + }; + } + + checkBudget(budget: OperationBudget): void { + if (budget.signal?.aborted) throw confinementError('ABORTED'); + if (Date.now() > budget.deadline) throw confinementError('TIMED_OUT'); + } + + async resolveExisting(requestedPath: string, expectDirectory: boolean, budget?: OperationBudget): Promise { + if (budget) this.checkBudget(budget); + const normalized = validateRequestedPath(requestedPath, expectDirectory); + const lexicalPath = path.resolve(this.root, normalized); + if (!isWithin(this.root, lexicalPath)) throw confinementError('ESCAPE'); + + let resolvedPath: string; + try { + resolvedPath = await realpath(lexicalPath); + } catch { + throw confinementError('NOT_FOUND'); + } + if (budget) this.checkBudget(budget); + if (!isWithin(this.root, resolvedPath)) throw confinementError('ESCAPE'); + + const requestedRelativePath = this.relativePath(lexicalPath); + const relativePath = this.relativePath(resolvedPath); + if (this.isDenied(requestedRelativePath) || this.isDenied(relativePath)) throw confinementError('DENIED'); + + const stats = await lstatForConfinement(resolvedPath, 'NOT_FOUND'); + if (budget) this.checkBudget(budget); + if (stats.isSymbolicLink()) throw confinementError('RACE_DETECTED'); + if (expectDirectory && !stats.isDirectory()) throw confinementError('NOT_DIRECTORY'); + if (!expectDirectory && !stats.isFile()) throw confinementError('NOT_REGULAR_FILE'); + return resolvedPath; + } + + async readFile(requestedPath: string, signal?: AbortSignal, maximumBytes = this.maxFileBytes): Promise { + const budget = this.createBudget(signal); + this.checkBudget(budget); + const resolvedPath = await this.resolveExisting(requestedPath, false, budget); + return this.readResolvedFile(resolvedPath, budget, maximumBytes); + } + + async readResolvedFile( + resolvedPath: string, + budget: OperationBudget, + maximumBytes = this.maxFileBytes, + ): Promise { + this.checkBudget(budget); + if (!isWithin(this.root, resolvedPath)) throw confinementError('ESCAPE'); + const relativePath = this.relativePath(resolvedPath); + if (this.isDenied(relativePath)) throw confinementError('DENIED'); + + // TOCTOU defense: lstat before opening, then require the open descriptor, + // the re-resolved path, and (on Linux) the descriptor's /proc target to all + // agree on one file identity inside the root. A swap at any point reads as + // RACE_DETECTED rather than serving bytes from outside the jail. + let before: Stats; + try { + before = await lstat(resolvedPath); + } catch { + throw confinementError('NOT_FOUND'); + } + if (!before.isFile() || before.isSymbolicLink()) throw confinementError('NOT_REGULAR_FILE'); + + let handle: FileHandle | undefined; + try { + handle = await open(resolvedPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + const opened = await handle.stat(); + if (!sameIdentity(before, opened)) throw confinementError('RACE_DETECTED'); + + const afterOpenPath = await realpath(resolvedPath); + if (afterOpenPath !== resolvedPath || !isWithin(this.root, afterOpenPath)) { + throw confinementError('RACE_DETECTED'); + } + + if (process.platform === 'linux') { + try { + const descriptorPath = await realpath(`/proc/self/fd/${handle.fd}`); + if (descriptorPath !== resolvedPath || !isWithin(this.root, descriptorPath)) { + throw confinementError('RACE_DETECTED'); + } + } catch (error) { + if (error instanceof ConfinementError) throw error; + throw confinementError('RACE_DETECTED'); + } + } + + const byteLimit = Math.min(Math.max(1, maximumBytes), this.maxFileBytes); + // One byte beyond the limit distinguishes a file exactly at the limit from a truncated one. + const output = Buffer.allocUnsafe(byteLimit + 1); + let offset = 0; + while (offset < output.length) { + this.checkBudget(budget); + const readResult = await handle.read(output, offset, output.length - offset, offset); + if (readResult.bytesRead === 0) break; + offset += readResult.bytesRead; + } + + const afterRead = await handle.stat(); + if (!sameIdentity(opened, afterRead)) throw confinementError('RACE_DETECTED'); + const finalPath = await realpath(resolvedPath); + if (finalPath !== resolvedPath || !isWithin(this.root, finalPath)) throw confinementError('RACE_DETECTED'); + + const truncated = offset > byteLimit; + return { + bytes: output.subarray(0, Math.min(offset, byteLimit)), + path: relativePath, + truncated, + }; + } catch (error) { + if (error instanceof ConfinementError) throw error; + // Unknown filesystem failures surface as RACE_DETECTED so no errno or host path escapes. + throw confinementError('RACE_DETECTED'); + } finally { + await handle?.close().catch(() => undefined); + } + } + + async enumerate( + requestedRoot: string, + signal?: AbortSignal, + operationBudget?: OperationBudget, + ): Promise { + const budget = operationBudget ?? this.createBudget(signal); + this.checkBudget(budget); + const searchRoot = await this.resolveExisting(requestedRoot || '.', true, budget); + const entries: ConfinedEntry[] = []; + const pending: Array<{ absolutePath: string; depth: number }> = [{ absolutePath: searchRoot, depth: 0 }]; + let visited = 0; + + while (pending.length > 0) { + this.checkBudget(budget); + const current = pending.pop(); + if (!current) break; + if (current.depth > this.maxDepth) throw confinementError('TOO_DEEP'); + + const directoryBefore = await lstatForConfinement(current.absolutePath, 'RACE_DETECTED'); + this.checkBudget(budget); + if (!directoryBefore.isDirectory() || directoryBefore.isSymbolicLink()) { + throw confinementError('RACE_DETECTED'); + } + const currentRealPath = await realpathForConfinement(current.absolutePath, 'RACE_DETECTED'); + if (currentRealPath !== current.absolutePath || !isWithin(this.root, currentRealPath)) { + throw confinementError('RACE_DETECTED'); + } + // A denied directory is pruned silently; its subtree simply does not exist to the tools. + const currentRelativePath = this.relativePath(currentRealPath); + if (this.isDenied(currentRelativePath)) continue; + + let directory: Dir; + try { + directory = await opendir(currentRealPath); + } catch { + throw confinementError('RACE_DETECTED'); + } + try { + for await (const dirent of directory) { + this.checkBudget(budget); + visited += 1; + if (visited > this.maxEntries) throw confinementError('TOO_MANY_ENTRIES'); + // Symlinks are skipped, not errors: the jail exposes only what physically lives under the root. + if (dirent.isSymbolicLink()) continue; + + const absolutePath = path.join(currentRealPath, dirent.name); + const entryBefore = await lstatForConfinement(absolutePath, 'RACE_DETECTED'); + this.checkBudget(budget); + if (entryBefore.isSymbolicLink()) continue; + + const resolvedEntryPath = await realpathForConfinement(absolutePath, 'RACE_DETECTED'); + this.checkBudget(budget); + if (resolvedEntryPath !== absolutePath || !isWithin(this.root, resolvedEntryPath)) { + throw confinementError('RACE_DETECTED'); + } + const entryAfter = await lstatForConfinement(resolvedEntryPath, 'RACE_DETECTED'); + this.checkBudget(budget); + if (!sameEntryIdentity(entryBefore, entryAfter)) throw confinementError('RACE_DETECTED'); + + const relativePath = this.relativePath(resolvedEntryPath); + if (this.isDenied(relativePath)) continue; + + if (entryAfter.isDirectory()) { + pending.push({ absolutePath: resolvedEntryPath, depth: current.depth + 1 }); + } else if (entryAfter.isFile()) { + entries.push({ absolutePath: resolvedEntryPath, path: relativePath, dirent }); + } + } + } catch (error) { + if (error instanceof ConfinementError) throw error; + throw confinementError('RACE_DETECTED'); + } finally { + await directory.close().catch(() => undefined); + } + + const directoryAfter = await lstatForConfinement(currentRealPath, 'RACE_DETECTED'); + this.checkBudget(budget); + if (!sameEntryIdentity(directoryBefore, directoryAfter)) throw confinementError('RACE_DETECTED'); + const finalDirectoryPath = await realpathForConfinement(currentRealPath, 'RACE_DETECTED'); + if (finalDirectoryPath !== currentRealPath || !isWithin(this.root, finalDirectoryPath)) { + throw confinementError('RACE_DETECTED'); + } + } + + // Traversal order is a LIFO stack; sorting makes the result deterministic for callers. + entries.sort((left, right) => left.path.localeCompare(right.path)); + return entries; + } +} + +/** Convert a bounded glob accepted by find/grep into a deterministic matcher. */ +export function compileRepositoryGlob(pattern: string): RegExp { + if ( + !pattern || + pattern.length > 256 || + pattern.includes('\0') || + pattern.includes('\\') || + path.isAbsolute(pattern) || + pattern.split('/').some((segment) => segment === '..' || segment === '') + ) { + throw confinementError('INVALID_PATH'); + } + return new RegExp(`^${globToRegexSource(pattern)}$`, 'u'); +} diff --git a/apps/worker/src/ai/sast/capella/tools/index.ts b/apps/worker/src/ai/sast/capella/tools/index.ts new file mode 100644 index 00000000..2f5beaaf --- /dev/null +++ b/apps/worker/src/ai/sast/capella/tools/index.ts @@ -0,0 +1,21 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +export { + type ConfinedEntry, + type ConfinedFile, + ConfinementError, + type ConfinementErrorCode, + type OperationBudget, + RepositoryConfinement, + type RepositoryConfinementOptions, +} from './confinement.js'; +export { + CAPELLA_REPOSITORY_TOOL_NAMES, + type CapellaRepositoryToolOptions, + createCapellaRepositoryTools, + isCapellaRepositoryTool, +} from './repository-tools.js'; diff --git a/apps/worker/src/ai/sast/capella/tools/repository-tools.ts b/apps/worker/src/ai/sast/capella/tools/repository-tools.ts new file mode 100644 index 00000000..d991f89a --- /dev/null +++ b/apps/worker/src/ai/sast/capella/tools/repository-tools.ts @@ -0,0 +1,377 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import path from 'node:path'; +import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent'; +import { Type } from 'typebox'; +import type { CapellaTool } from '../../../pi/capella-agent-types.js'; +import { + type ConfinedEntry, + type ConfinedFile, + ConfinementError, + compileRepositoryGlob, + type OperationBudget, + RepositoryConfinement, + type RepositoryConfinementOptions, +} from './confinement.js'; + +const MAX_READ_OUTPUT_BYTES = 64 * 1024; +const MAX_READ_LINES = 1_000; +const MAX_FIND_RESULTS = 500; +const DEFAULT_FIND_RESULTS = 100; +const MAX_GREP_MATCHES = 200; +const DEFAULT_GREP_MATCHES = 100; +const MAX_GREP_CONTEXT = 10; +const MAX_GREP_PATTERN_LENGTH = 256; +const MAX_GREP_LINE_BYTES = 8 * 1024; +const MAX_GREP_OUTPUT_BYTES = 64 * 1024; +const MAX_GREP_SCANNED_BYTES = 2 * 1024 * 1024; + +export const CAPELLA_REPOSITORY_TOOL_NAMES = ['read', 'find', 'grep'] as const; + +// Tool identity is tracked by reference so the executor can verify a read/find/grep +// definition came from this confined factory rather than trusting its name. +const repositoryTools = new WeakSet(); + +export interface CapellaRepositoryToolOptions extends RepositoryConfinementOptions {} + +interface ReadDetails { + readonly path: string; + readonly truncated: boolean; +} + +interface FindDetails { + readonly count: number; + readonly truncated: boolean; +} + +interface GrepDetails { + readonly matchCount: number; + readonly filesScanned: number; + readonly truncated: boolean; +} + +function markRepositoryTool(tool: T): T { + repositoryTools.add(tool); + return tool; +} + +/** Whether a read/find/grep definition came from the confined Capella factory. */ +export function isCapellaRepositoryTool(tool: CapellaTool): boolean { + return repositoryTools.has(tool); +} + +/** Truncate to a byte budget without splitting a multi-byte character. */ +function boundedText(text: string, maximumBytes: number): { text: string; truncated: boolean } { + const bytes = Buffer.from(text, 'utf8'); + if (bytes.byteLength <= maximumBytes) return { text, truncated: false }; + const suffix = `\n[Output truncated at ${maximumBytes} bytes.]`; + const contentLimit = Math.max(0, maximumBytes - Buffer.byteLength(suffix)); + let prefix = bytes.subarray(0, contentLimit).toString('utf8'); + while (Buffer.byteLength(prefix) > contentLimit) prefix = prefix.slice(0, -1); + return { + text: `${prefix}${suffix}`, + truncated: true, + }; +} + +function sliceReadOutput(file: ConfinedFile, offset: number, limit: number): { text: string; truncated: boolean } { + if (file.bytes.includes(0)) { + throw new ConfinementError('NOT_REGULAR_FILE', 'Binary repository files are not available to Capella.'); + } + + const normalized = file.bytes.toString('utf8').replace(/\r\n?/gu, '\n'); + const lines = normalized.split('\n'); + const start = offset - 1; + const selected = lines.slice(start, start + limit); + const bounded = boundedText(selected.join('\n'), MAX_READ_OUTPUT_BYTES); + const lineTruncated = start + selected.length < lines.length; + return { text: bounded.text, truncated: file.truncated || lineTruncated || bounded.truncated }; +} + +function createReadTool(confinement: RepositoryConfinement): ToolDefinition { + return markRepositoryTool( + defineTool({ + name: 'read', + label: 'Read repository file', + description: 'Read a bounded UTF-8 text file inside the configured repository root.', + promptSnippet: 'read: inspect a bounded repository text file', + promptGuidelines: ['Use only repository-relative paths. Absolute paths and traversal are rejected.'], + parameters: Type.Object( + { + path: Type.String({ minLength: 1, maxLength: 1_024 }), + offset: Type.Optional(Type.Integer({ minimum: 1, maximum: 100_000 })), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_LINES })), + }, + { additionalProperties: false }, + ), + async execute(_toolCallId, parameters, signal) { + const budget = confinement.createBudget(signal); + const resolvedPath = await confinement.resolveExisting(parameters.path, false, budget); + const file = await confinement.readResolvedFile(resolvedPath, budget); + const output = sliceReadOutput(file, parameters.offset ?? 1, parameters.limit ?? MAX_READ_LINES); + confinement.checkBudget(budget); + const details: ReadDetails = { path: file.path, truncated: output.truncated }; + return { + content: [{ type: 'text' as const, text: output.text }], + details, + }; + }, + }), + ); +} + +function relativeToSearchRoot(searchRoot: string, entry: ConfinedEntry): string { + const relativePath = path.relative(searchRoot, entry.absolutePath); + if (relativePath === '' || relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) { + throw new ConfinementError('RACE_DETECTED', 'Repository path changed during enumeration.'); + } + return relativePath.split(path.sep).join('/'); +} + +function createFindTool(confinement: RepositoryConfinement): ToolDefinition { + return markRepositoryTool( + defineTool({ + name: 'find', + label: 'Find repository files', + description: 'Find bounded repository-relative file paths without following symlinks.', + promptSnippet: 'find: list repository files matching a bounded glob', + promptGuidelines: ['Search roots and returned paths are repository-relative.'], + parameters: Type.Object( + { + pattern: Type.String({ minLength: 1, maxLength: 256 }), + path: Type.Optional(Type.String({ minLength: 1, maxLength: 1_024 })), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_FIND_RESULTS })), + }, + { additionalProperties: false }, + ), + async execute(_toolCallId, parameters, signal) { + const budget = confinement.createBudget(signal); + const searchRoot = await confinement.resolveExisting(parameters.path ?? '.', true, budget); + const matcher = compileRepositoryGlob(parameters.pattern); + const maximumResults = parameters.limit ?? DEFAULT_FIND_RESULTS; + const entries = await confinement.enumerate(parameters.path ?? '.', signal, budget); + const matches: string[] = []; + let resultLimitReached = false; + + for (const entry of entries) { + confinement.checkBudget(budget); + const relativeSearchPath = relativeToSearchRoot(searchRoot, entry); + if (!matcher.test(relativeSearchPath)) continue; + if (matches.length >= maximumResults) { + resultLimitReached = true; + break; + } + matches.push(entry.path); + } + + const bounded = boundedText(matches.join('\n') || 'No files found.', MAX_READ_OUTPUT_BYTES); + confinement.checkBudget(budget); + const details: FindDetails = { + count: matches.length, + truncated: resultLimitReached || bounded.truncated, + }; + return { content: [{ type: 'text' as const, text: bounded.text }], details }; + }, + }), + ); +} + +/** + * Restrict grep patterns to a subset with no quantifiers, alternation, groups, + * or backreferences, so a model-authored pattern cannot trigger catastrophic + * backtracking against repository text. + */ +function assertSafeRegex(pattern: string): void { + let escaped = false; + let insideClass = false; + for (const character of pattern) { + if (escaped) { + if (/[1-9]/u.test(character)) { + throw new ConfinementError('INVALID_PATH', 'Grep pattern is outside the bounded regular-expression subset.'); + } + escaped = false; + continue; + } + if (character === '\\') { + escaped = true; + continue; + } + if (character === '[') { + insideClass = true; + continue; + } + if (character === ']' && insideClass) { + insideClass = false; + continue; + } + if (!insideClass && '()*+?{|}'.includes(character)) { + throw new ConfinementError('INVALID_PATH', 'Grep pattern is outside the bounded regular-expression subset.'); + } + } +} + +function compileGrepPattern(pattern: string, literal: boolean, ignoreCase: boolean): RegExp { + if (pattern.includes('\0')) { + throw new ConfinementError('INVALID_PATH', 'Grep pattern is invalid.'); + } + const flags = ignoreCase ? 'iu' : 'u'; + if (literal) { + const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + return new RegExp(escaped, flags); + } + assertSafeRegex(pattern); + try { + return new RegExp(pattern, flags); + } catch { + throw new ConfinementError('INVALID_PATH', 'Grep pattern is invalid.'); + } +} + +function lineForSearch(line: string): string { + const bytes = Buffer.from(line, 'utf8'); + if (bytes.byteLength <= MAX_GREP_LINE_BYTES) return line; + return bytes.subarray(0, MAX_GREP_LINE_BYTES).toString('utf8'); +} + +function formatGrepBlock(filePath: string, lines: readonly string[], lineIndex: number, context: number): string[] { + const output: string[] = []; + const start = Math.max(0, lineIndex - context); + const end = Math.min(lines.length - 1, lineIndex + context); + for (let index = start; index <= end; index += 1) { + const separator = index === lineIndex ? ':' : '-'; + const bounded = boundedText(lines[index] ?? '', 1_000); + output.push(`${filePath}${separator}${index + 1}${separator} ${bounded.text}`); + } + return output; +} + +async function grepCandidate( + confinement: RepositoryConfinement, + entry: ConfinedEntry, + matcher: RegExp, + context: number, + remainingMatches: number, + budget: OperationBudget, +): Promise<{ readonly blocks: string[][]; readonly bytesScanned: number; readonly truncated: boolean }> { + const file = await confinement.readResolvedFile(entry.absolutePath, budget); + if (file.bytes.includes(0)) return { blocks: [], bytesScanned: file.bytes.byteLength, truncated: file.truncated }; + + const lines = file.bytes.toString('utf8').replace(/\r\n?/gu, '\n').split('\n'); + const blocks: string[][] = []; + for (let lineIndex = 0; lineIndex < lines.length && blocks.length < remainingMatches; lineIndex += 1) { + confinement.checkBudget(budget); + matcher.lastIndex = 0; + if (!matcher.test(lineForSearch(lines[lineIndex] ?? ''))) continue; + blocks.push(formatGrepBlock(file.path, lines, lineIndex, context)); + } + return { blocks, bytesScanned: file.bytes.byteLength, truncated: file.truncated }; +} + +/** + * Grep accepts a directory or a single file. Only NOT_DIRECTORY falls through + * to the single-file path; every other confinement failure propagates. + */ +async function resolveGrepCandidates( + confinement: RepositoryConfinement, + requestedPath: string, + budget: OperationBudget, +): Promise { + try { + await confinement.resolveExisting(requestedPath, true, budget); + return confinement.enumerate(requestedPath, undefined, budget); + } catch (error) { + if (!(error instanceof ConfinementError) || error.code !== 'NOT_DIRECTORY') throw error; + } + + const resolvedPath = await confinement.resolveExisting(requestedPath, false, budget); + return [ + { + absolutePath: resolvedPath, + path: confinement.relativePath(resolvedPath), + dirent: { + isFile: () => true, + } as ConfinedEntry['dirent'], + }, + ]; +} + +function createGrepTool(confinement: RepositoryConfinement): ToolDefinition { + return markRepositoryTool( + defineTool({ + name: 'grep', + label: 'Search repository contents', + description: 'Search bounded repository text through the same no-follow read boundary as read.', + promptSnippet: 'grep: search bounded repository text', + promptGuidelines: ['Patterns, search roots, and optional globs are bounded and contain no shell arguments.'], + parameters: Type.Object( + { + pattern: Type.String({ minLength: 1, maxLength: MAX_GREP_PATTERN_LENGTH }), + path: Type.Optional(Type.String({ minLength: 1, maxLength: 1_024 })), + glob: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })), + ignoreCase: Type.Optional(Type.Boolean()), + literal: Type.Optional(Type.Boolean()), + context: Type.Optional(Type.Integer({ minimum: 0, maximum: MAX_GREP_CONTEXT })), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_GREP_MATCHES })), + }, + { additionalProperties: false }, + ), + async execute(_toolCallId, parameters, signal) { + const matcher = compileGrepPattern( + parameters.pattern, + parameters.literal ?? false, + parameters.ignoreCase ?? false, + ); + const globMatcher = parameters.glob ? compileRepositoryGlob(parameters.glob) : undefined; + const maximumMatches = parameters.limit ?? DEFAULT_GREP_MATCHES; + const budget = confinement.createBudget(signal); + const candidates = await resolveGrepCandidates(confinement, parameters.path ?? '.', budget); + const blocks: string[][] = []; + let filesScanned = 0; + let bytesScanned = 0; + let sourceTruncated = false; + + for (const entry of candidates) { + confinement.checkBudget(budget); + if (globMatcher && !globMatcher.test(entry.path)) continue; + if (blocks.length >= maximumMatches || bytesScanned >= MAX_GREP_SCANNED_BYTES) break; + + const result = await grepCandidate( + confinement, + entry, + matcher, + parameters.context ?? 0, + maximumMatches - blocks.length, + budget, + ); + blocks.push(...result.blocks); + filesScanned += 1; + bytesScanned += result.bytesScanned; + sourceTruncated ||= result.truncated; + } + + const rawOutput = blocks.map((block) => block.join('\n')).join('\n--\n') || 'No matches found.'; + const bounded = boundedText(rawOutput, MAX_GREP_OUTPUT_BYTES); + confinement.checkBudget(budget); + const truncated = + sourceTruncated || + blocks.length >= maximumMatches || + bytesScanned >= MAX_GREP_SCANNED_BYTES || + bounded.truncated; + const details: GrepDetails = { matchCount: blocks.length, filesScanned, truncated }; + return { content: [{ type: 'text' as const, text: bounded.text }], details }; + }, + }), + ); +} + +/** Create the exact three Capella-owned repository tools for one immutable policy. */ +export async function createCapellaRepositoryTools( + options: CapellaRepositoryToolOptions, +): Promise { + const confinement = await RepositoryConfinement.create(options); + return Object.freeze([createReadTool(confinement), createFindTool(confinement), createGrepTool(confinement)]); +} diff --git a/apps/worker/src/ai/sast/capella/types.ts b/apps/worker/src/ai/sast/capella/types.ts new file mode 100644 index 00000000..8b004613 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/types.ts @@ -0,0 +1,283 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import type { CapellaAgentExecutor, CapellaTool } from '../../pi/capella-agent-types.js'; +import type { + AgenticSastArchitectureReduction, + AgenticSastCalibrateReduction, + AgenticSastConfirmReduction, + AgenticSastCriticReduction, + AgenticSastDedupeReduction, + AgenticSastFallbackReduction, + AgenticSastPlanReduction, + AgenticSastReduction, + AgenticSastResearchReduction, + AgenticSastReviewReduction, + CapellaFallbackStage, + CapellaStage, + CapellaUsage, + SarifRef, +} from '../types.js'; +import type { CapellaFinding } from './finding-types.js'; +import type { KbResult, PlanResult, ThreatModelResult } from './schemas.js'; + +// Both versions are identity fields of every run fingerprint and run.json record. Routine +// prompt edits are already covered by each stage's rendered-prompt digest; bump this global +// prompt contract only when a cross-stage change must invalidate every Capella artifact. +export const CAPELLA_FORMAT_VERSION = '1'; +export const CAPELLA_PROMPT_SET_VERSION = 'capella-prompts.v1'; +export const CAPELLA_TRIAGE_CONCURRENCY = 4; +export const CAPELLA_AUDIT_CONCURRENCY = 2; + +export const ZERO_CAPELLA_USAGE: CapellaUsage = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + costUsd: 0, + turns: 0, +}; + +/** Immutable reference to a completed, fingerprinted Capella artifact. */ +export interface CapellaArtifactRef { + readonly path: string; + readonly sha256: string; + readonly fingerprint: string; +} + +/** Serializable inputs shared by every activity-side stage implementation. */ +export interface CapellaStageInput { + readonly repoPath: string; + readonly artifactRoot: string; + readonly workflowLogPath: string; + readonly promptDir: string; + readonly modelSpec: string; + readonly capellaFormatVersion: string; + readonly promptSetVersion: string; + readonly codePathAvoids: readonly string[]; + readonly codePathFocus: readonly string[]; + readonly pipelineTestingMode: boolean; + readonly timeoutMs: number; +} + +/** Non-serializable activity dependencies supplied by the Temporal wrapper. */ +export interface CapellaStageRuntime { + readonly executor: CapellaAgentExecutor; + readonly repositoryTools: readonly CapellaTool[]; + readonly signal: AbortSignal; +} + +export interface CompletedStage { + readonly status: 'completed'; + readonly durationMs: number; + readonly reused: boolean; + readonly usage: CapellaUsage; + readonly artifact: CapellaArtifactRef; + readonly value: T; +} + +export interface ArchitectureValue { + readonly knowledgeBase: KbResult; + readonly componentCount: number; + readonly reduction?: AgenticSastArchitectureReduction; +} + +export interface ThreatModelValue extends ThreatModelResult { + readonly threatModelPath: string; +} + +export interface PlanValue extends PlanResult { + readonly investigationCount: number; + readonly reduction?: AgenticSastPlanReduction; +} + +export interface FindingSetValue { + readonly findings: CapellaFinding[]; +} + +/** + * Deterministic triage-coverage result for the research stage. `missingFiles` is the private + * detailed evidence of which assigned paths were not classified; it stays in this artifact and + * is never projected into a public surface (only the counts are). + */ +export interface ResearchCoverage { + readonly consideredCount: number; + readonly classifiedCount: number; + readonly omittedCount: number; + readonly affectedBatchCount: number; + readonly missingFiles: readonly string[]; +} + +/** Private audit-unit evidence retained in the research artifact. */ +export interface ResearchAuditCoverage { + readonly consideredCount: number; + readonly completedCount: number; + readonly salvagedSessionCount: number; +} + +export interface ResearchValue extends FindingSetValue { + readonly flaggedFiles: string[]; + readonly dispatchedCount: number; + readonly resumedCount: number; + readonly coverage: 'complete' | 'reduced'; + readonly triageCoverage: ResearchCoverage; + readonly auditCoverage: ResearchAuditCoverage; + readonly reduction?: AgenticSastResearchReduction; +} + +export interface DedupeValue extends FindingSetValue { + readonly duplicateCount: number; + readonly survivorCount: number; + readonly reduction?: AgenticSastDedupeReduction; +} + +interface VerdictStageDiagnostics { + /** Private collector diagnostics; compact activity values omit these when coverage is complete. */ + readonly rejectedUnexpectedCount: number; + readonly rejectedDuplicateCount: number; +} + +export interface ReviewValue extends FindingSetValue, VerdictStageDiagnostics { + readonly validCount: number; + readonly provisionalCount: number; + readonly falsePositiveCount: number; + readonly reduction?: AgenticSastReviewReduction; +} + +export interface CriticValue extends FindingSetValue, VerdictStageDiagnostics { + readonly viableCount: number; + readonly reduction?: AgenticSastCriticReduction; +} + +export interface ConfirmValue extends FindingSetValue, VerdictStageDiagnostics { + readonly confirmedCount: number; + readonly reduction?: AgenticSastConfirmReduction; +} + +export interface CalibrateValue extends FindingSetValue, VerdictStageDiagnostics { + readonly calibratedCount: number; + readonly reduction?: AgenticSastCalibrateReduction; +} + +export interface ExportValue { + readonly sarif: SarifRef; + readonly findingCount: number; + readonly coverage: 'complete' | 'reduced'; + readonly warnings: string[]; + readonly reportPath: string; + readonly reduction?: AgenticSastReduction; +} + +export interface FindingStageInput extends CapellaStageInput { + readonly findingsArtifact: CapellaArtifactRef; +} + +export interface KnowledgeFindingStageInput extends FindingStageInput { + readonly architectureArtifact: CapellaArtifactRef; + readonly threatModelArtifact: CapellaArtifactRef; +} + +export interface ThreatModelStageInput extends CapellaStageInput { + readonly architectureArtifact: CapellaArtifactRef; +} + +export interface PlanStageInput extends CapellaStageInput { + readonly architectureArtifact: CapellaArtifactRef; + readonly threatModelArtifact: CapellaArtifactRef; +} + +export interface ResearchStageInput extends CapellaStageInput { + readonly architectureArtifact: CapellaArtifactRef; + readonly planArtifact: CapellaArtifactRef; +} + +export type ExportSourceStage = 'research' | 'dedupe' | 'review' | 'critic' | 'confirm' | 'calibrate'; + +export interface ExportStageInput extends CapellaStageInput { + readonly findingsArtifact?: CapellaArtifactRef; + readonly findingsStage?: ExportSourceStage; + readonly repositoryLabel: string; + readonly fallbackReduction?: AgenticSastFallbackReduction; + readonly fallbackFailure?: CapellaFallbackFailure; +} + +/** Bounded original failure sent back into a last-good export activity. */ +export interface CapellaFallbackFailure { + readonly stage: CapellaFallbackStage; + readonly code: string; + readonly error: string; + readonly attempt: number; + readonly retryable: boolean; +} + +export interface CapellaArtifactEnvelope { + readonly schemaVersion: 1; + readonly stage: CapellaStage; + readonly fingerprint: string; + readonly usage: CapellaUsage; + readonly value: T; +} + +export type StageArtifactValidator = (value: unknown) => value is T; + +export interface AtomicPublishOptions { + readonly beforeRename?: (temporaryPath: string, finalPath: string) => Promise | void; +} + +export interface CapellaRunFailure { + readonly stage: CapellaStage | 'workflow'; + readonly code: string; + readonly error: string; + readonly attempt: number; + readonly retryable: boolean; +} + +/** + * A stage's spend folded from its per-attempt usage ledger. `complete` requires a matching + * final record for every started session; `retried` reports whether more than one activity + * attempt touched the stage. Usage accounting is trusted only when `complete && !retried`, + * because an attempt that died mid-session cannot prove its spend was fully captured. + */ +export interface StageUsageSummary { + readonly usage: CapellaUsage; + readonly complete: boolean; + readonly retried: boolean; +} + +export interface CapellaRunRecord { + readonly schemaVersion: 1; + readonly capellaFormatVersion: string; + readonly promptSetVersion: string; + readonly inputFingerprint: string; + readonly completedStages: CapellaStage[]; + readonly finalState: 'running' | 'succeeded' | 'failed'; + readonly warnings: string[]; + readonly usage: CapellaUsage; + readonly stageUsage: Partial>; + // True only while every recorded stage's spend was captured from a clean, un-retried + // ledger. A retried or terminally failed stage drives this false; the reason is named in + // `warnings`. Consumers treating run.json as the billing record read this before trusting `usage`. + readonly usageAccountingComplete: boolean; + // Aggregate reduced-coverage summary, at most one entry per stage, in stage order. Stage + // reductions are counts-only; export omissions may include bounded finding identity for private + // diagnostics. Derived from the same structured facts the stage artifacts hold. + readonly reductions?: readonly AgenticSastReduction[]; + readonly sarif?: SarifRef; + readonly failure?: CapellaRunFailure; +} + +/** + * Human-readable reason a stage's usage accounting could not be fully trusted. + * + * The single source of the warning text: run.json (`recordRunFailure`, + * `recordStageUsageAccounting`), the activity failure payload, and the workflow fold all + * emit this string for a stage whose ledger did not reconcile (`complete && !retried`), so + * every ledger surfaces the identical reason. Lives here because it is shared across the + * activity side and the workflow isolate. + */ +export function usageAccountingWarning(stage: CapellaStage | 'workflow'): string { + return `Usage accounting for stage "${stage}" is incomplete: the stage was retried or failed, so a failed attempt's spend may not be fully captured in run.json.`; +} diff --git a/apps/worker/src/ai/sast/capella/validation.ts b/apps/worker/src/ai/sast/capella/validation.ts new file mode 100644 index 00000000..912247c4 --- /dev/null +++ b/apps/worker/src/ai/sast/capella/validation.ts @@ -0,0 +1,607 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import type { AgenticSastReduction } from '../types.js'; +import { SastContractError } from './errors.js'; +import { + CAPELLA_ATTACKER_POSITIONS, + CAPELLA_PRIVILEGES, + CAPELLA_REPRO_STATUSES, + CAPELLA_SEVERITIES, + CAPELLA_STATUSES, + CAPELLA_USER_INTERACTIONS, + CAPELLA_VIABILITIES, + type CapellaFinding, +} from './finding-types.js'; +import { isNormalizedRepositoryPath } from './paths.js'; +import type { KbResult, PlanResult, ThreatModelResult, TriageResult } from './schemas.js'; +import type { + ArchitectureValue, + CalibrateValue, + ConfirmValue, + CriticValue, + DedupeValue, + ExportValue, + PlanValue, + ResearchValue, + ReviewValue, + ThreatModelValue, +} from './types.js'; + +const CWE_PATTERN = /^CWE-\d+$/; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => isNonEmptyString(entry)); +} + +function isEnum(value: unknown, allowed: readonly T[]): value is T { + return typeof value === 'string' && (allowed as readonly string[]).includes(value); +} + +/** Validate the stage-to-stage finding record before it is reused or exported. */ +export function isCapellaFinding(value: unknown): value is CapellaFinding { + if (!isRecord(value)) return false; + // Mirrors the collector's finding-id sanitizer: the id names findings/.json, + // so a separator or traversal here would escape the findings directory. + if (!isNonEmptyString(value.id) || value.id.includes('/') || value.id.includes('\\') || value.id.includes('..')) { + return false; + } + if (!isNonEmptyString(value.title) || !isNonEmptyString(value.description)) return false; + if (!isStringArray(value.code_paths)) return false; + if (!isNonEmptyString(value.impact) || !isNonEmptyString(value.mitigation)) return false; + if (!isEnum(value.severity, CAPELLA_SEVERITIES)) return false; + if (!isEnum(value.privileges_required, CAPELLA_PRIVILEGES)) return false; + if (!isEnum(value.attacker_position, CAPELLA_ATTACKER_POSITIONS)) return false; + if (!isEnum(value.user_interaction, CAPELLA_USER_INTERACTIONS)) return false; + if (!isNonEmptyString(value.cwe) || !CWE_PATTERN.test(value.cwe)) return false; + if (!isEnum(value.status, CAPELLA_STATUSES)) return false; + if (!Array.isArray(value.history) || !value.history.every(isRecord)) return false; + if (typeof value.recordedAt !== 'number' || !Number.isFinite(value.recordedAt)) return false; + if (value.production_viability !== undefined && !isEnum(value.production_viability, CAPELLA_VIABILITIES)) + return false; + if (value.repro_status !== undefined && !isEnum(value.repro_status, CAPELLA_REPRO_STATUSES)) return false; + return true; +} + +export function isFindingSetValue(value: unknown): value is { findings: CapellaFinding[] } { + return isRecord(value) && Array.isArray(value.findings) && value.findings.every(isCapellaFinding); +} + +export function isRawFindingSetValue(value: unknown): value is { findings: unknown[] } { + return isRecord(value) && Array.isArray(value.findings); +} + +export function assertFindingSet(value: unknown, source: string): asserts value is { findings: CapellaFinding[] } { + if (!isFindingSetValue(value)) { + throw new SastContractError(`${source} is not a valid Capella finding set`, 'FINDING_SET_SCHEMA'); + } +} + +export interface VerdictSetDetails { + readonly expectedCount: number; + readonly receivedCount: number; + readonly missingIds: readonly string[]; + readonly duplicateIds: readonly string[]; + readonly unexpectedIds: readonly string[]; +} + +/** Calculate exact-set completeness without mutating findings or throwing. */ +export function calculateVerdictSetDetails( + expectedIds: readonly string[], + recordedIds: readonly string[], +): VerdictSetDetails { + const expected = [...expectedIds].sort(); + const recorded = [...recordedIds].sort(); + const expectedSet = new Set(expected); + const recordedSet = new Set(recorded); + return { + expectedCount: expected.length, + receivedCount: recorded.length, + missingIds: [...new Set(expected.filter((id) => !recordedSet.has(id)))], + duplicateIds: [...new Set(recorded.filter((id, index) => index > 0 && id === recorded[index - 1]))], + unexpectedIds: [...new Set(recorded.filter((id) => !expectedSet.has(id)))], + }; +} + +export function isKbEntity(value: unknown): value is KbResult['entities'][number] { + return isRecord(value) && isNonEmptyString(value.name) && isNonEmptyString(value.content); +} + +export interface SalvagedKbResult { + readonly value: KbResult; + readonly consideredEntityCount: number; + readonly omittedEntityCount: number; + readonly consideredDependencyCount: number; + readonly omittedDependencyCount: number; +} + +/** Keep a valid KB core while dropping malformed entities and dependency edges. */ +export function salvageKbResult(value: unknown): SalvagedKbResult | undefined { + if (!isRecord(value)) return undefined; + if (!isNonEmptyString(value.architecture) || !isNonEmptyString(value.index)) return undefined; + if (!Array.isArray(value.entities) || !Array.isArray(value.vulnerabilities) || !isRecord(value.dependencies)) { + return undefined; + } + + const rawEntities = [...value.entities, ...value.vulnerabilities]; + const entities = value.entities.filter(isKbEntity); + const vulnerabilities = value.vulnerabilities.filter(isKbEntity); + const dependencyEntries = Object.entries(value.dependencies); + const dependencies = Object.fromEntries(dependencyEntries.filter(([, targets]) => isStringArray(targets))) as Record< + string, + string[] + >; + return { + value: { + architecture: value.architecture, + entities, + vulnerabilities, + index: value.index, + dependencies, + }, + consideredEntityCount: rawEntities.length, + omittedEntityCount: rawEntities.length - entities.length - vulnerabilities.length, + consideredDependencyCount: dependencyEntries.length, + omittedDependencyCount: dependencyEntries.length - Object.keys(dependencies).length, + }; +} + +export function isKbResult(value: unknown): value is KbResult { + if (!isRecord(value)) return false; + if (!isNonEmptyString(value.architecture) || !isNonEmptyString(value.index)) return false; + if (!Array.isArray(value.entities) || !value.entities.every(isKbEntity)) return false; + if (!Array.isArray(value.vulnerabilities) || !value.vulnerabilities.every(isKbEntity)) return false; + if (!isRecord(value.dependencies)) return false; + return Object.values(value.dependencies).every(isStringArray); +} + +export function isArchitectureValue(value: unknown): value is ArchitectureValue { + return ( + isRecord(value) && + isKbResult(value.knowledgeBase) && + Number.isSafeInteger(value.componentCount) && + (value.reduction === undefined || + (isAgenticSastReduction(value.reduction) && + value.reduction.stage === 'architecture' && + value.reduction.reason === 'invalid_architecture_items')) + ); +} + +export function isThreatModelResult(value: unknown): value is ThreatModelResult { + return ( + isRecord(value) && + isNonEmptyString(value.threatModel) && + (value.intent === 'PRODUCTION' || value.intent === 'SAMPLE_OR_TEST_ONLY') + ); +} + +export function isThreatModelValue(value: unknown): value is ThreatModelValue { + if (!isThreatModelResult(value)) return false; + const record = value as ThreatModelResult & Record; + return isNonEmptyString(record.threatModelPath); +} + +export function isInvestigation(value: unknown): value is PlanResult['investigations'][number] { + if (!isRecord(value)) return false; + return ( + isNonEmptyString(value.title) && + isStringArray(value.target_files) && + value.target_files.every(isNormalizedRepositoryPath) && + Array.isArray(value.kb_references) && + value.kb_references.every((entry) => typeof entry === 'string') && + isNonEmptyString(value.question) + ); +} + +export interface SalvagedPlanResult { + readonly value: PlanResult; + readonly consideredCount: number; + readonly omittedCount: number; +} + +/** Keep usable investigations while preserving root invalidity as an atomic failure. */ +export function salvagePlanResult(value: unknown): SalvagedPlanResult | undefined { + if (!isRecord(value) || !Array.isArray(value.investigations)) return undefined; + const investigations = value.investigations.filter(isInvestigation); + return { + value: { investigations }, + consideredCount: value.investigations.length, + omittedCount: value.investigations.length - investigations.length, + }; +} + +export function isPlanResult(value: unknown): value is PlanResult { + const salvaged = salvagePlanResult(value); + return salvaged !== undefined && salvaged.omittedCount === 0; +} + +export function isPlanValue(value: unknown): value is PlanValue { + if (!isPlanResult(value)) return false; + const record = value as PlanResult & Record; + const reduction = record.reduction; + return ( + value.investigations.length > 0 && + record.investigationCount === value.investigations.length && + (reduction === undefined || + (isAgenticSastReduction(reduction) && + reduction.stage === 'plan' && + reduction.reason === 'invalid_investigations' && + reduction.usableCount === value.investigations.length)) + ); +} + +export function isTriageResult(value: unknown): value is TriageResult { + return ( + isRecord(value) && + Array.isArray(value.classifications) && + value.classifications.every( + (classification) => + isRecord(classification) && + isNormalizedRepositoryPath(String(classification.file)) && + typeof classification.potentially_flawed === 'boolean' && + typeof classification.reason === 'string', + ) + ); +} + +function hasInteger(value: Record, key: string): boolean { + return Number.isSafeInteger(value[key]) && Number(value[key]) >= 0; +} + +function isResearchCoverage(value: unknown): value is ResearchValue['triageCoverage'] { + if (!isRecord(value)) return false; + if ( + !hasInteger(value, 'consideredCount') || + !hasInteger(value, 'classifiedCount') || + !hasInteger(value, 'omittedCount') || + !hasInteger(value, 'affectedBatchCount') + ) { + return false; + } + if (Number(value.classifiedCount) + Number(value.omittedCount) !== Number(value.consideredCount)) return false; + return ( + Array.isArray(value.missingFiles) && + value.missingFiles.every((file) => typeof file === 'string' && isNormalizedRepositoryPath(file)) && + value.missingFiles.length === Number(value.omittedCount) + ); +} + +function isResearchAuditCoverage(value: unknown): value is ResearchValue['auditCoverage'] { + if (!isRecord(value)) return false; + if ( + !hasInteger(value, 'consideredCount') || + !hasInteger(value, 'completedCount') || + !hasInteger(value, 'salvagedSessionCount') + ) { + return false; + } + return Number(value.completedCount) === Number(value.consideredCount); +} + +export function isResearchValue(value: unknown): value is ResearchValue { + if (!isFindingSetValue(value)) return false; + const record = value as { findings: CapellaFinding[] } & Record; + const reduction = record.reduction; + const triageCoverage = record.triageCoverage; + const auditCoverage = record.auditCoverage; + const coverageIsValid = isResearchCoverage(triageCoverage) && isResearchAuditCoverage(auditCoverage); + const reductionMatches = + reduction === undefined || + (coverageIsValid && + isAgenticSastReduction(reduction) && + reduction.stage === 'research' && + reduction.reason === 'incomplete_research' && + reduction.triageConsideredCount === triageCoverage.consideredCount && + reduction.triageClassifiedCount === triageCoverage.classifiedCount && + reduction.triageOmittedCount === triageCoverage.omittedCount && + reduction.affectedTriageBatchCount === triageCoverage.affectedBatchCount && + reduction.auditUnitCount === auditCoverage.consideredCount && + reduction.salvagedAuditSessionCount === auditCoverage.salvagedSessionCount); + return ( + Array.isArray(record.flaggedFiles) && + record.flaggedFiles.every((file) => typeof file === 'string' && isNormalizedRepositoryPath(file)) && + hasInteger(record, 'dispatchedCount') && + hasInteger(record, 'resumedCount') && + (record.coverage === 'complete' || record.coverage === 'reduced') && + coverageIsValid && + reductionMatches && + (record.coverage === 'reduced') === (reduction !== undefined) + ); +} + +function hasValidOptionalReduction( + value: unknown, + stage: AgenticSastReduction['stage'], + reason: AgenticSastReduction['reason'], +): boolean { + const record = value as Record; + return ( + record.reduction === undefined || + (isAgenticSastReduction(record.reduction) && record.reduction.stage === stage && record.reduction.reason === reason) + ); +} + +function hasPrivateVerdictDiagnostics(value: Record): boolean { + return hasInteger(value, 'rejectedUnexpectedCount') && hasInteger(value, 'rejectedDuplicateCount'); +} + +function hasMatchingVerdictReduction( + value: Record, + stage: 'review' | 'critic' | 'confirm' | 'calibrate', + reason: 'incomplete_review' | 'incomplete_critic' | 'incomplete_confirm' | 'incomplete_calibrate', +): boolean { + if (!hasValidOptionalReduction(value, stage, reason)) return false; + if (value.reduction === undefined) return true; + const reduction = value.reduction; + const reductionRecord = reduction as Record; + return ( + isAgenticSastReduction(reduction) && + reductionRecord.rejectedUnexpectedCount === value.rejectedUnexpectedCount && + reductionRecord.rejectedDuplicateCount === value.rejectedDuplicateCount + ); +} + +export function isDedupeValue(value: unknown): value is DedupeValue { + return ( + isFindingSetValue(value) && + hasInteger(value, 'duplicateCount') && + hasInteger(value, 'survivorCount') && + Number((value as Record).survivorCount) === value.findings.length && + hasValidOptionalReduction(value, 'dedupe', 'incomplete_dedupe') + ); +} + +export function isReviewValue(value: unknown): value is ReviewValue { + return ( + isFindingSetValue(value) && + hasInteger(value, 'validCount') && + hasInteger(value, 'provisionalCount') && + hasInteger(value, 'falsePositiveCount') && + hasPrivateVerdictDiagnostics(value) && + hasMatchingVerdictReduction(value, 'review', 'incomplete_review') + ); +} + +export function isCriticValue(value: unknown): value is CriticValue { + return ( + isFindingSetValue(value) && + hasInteger(value, 'viableCount') && + hasPrivateVerdictDiagnostics(value) && + hasMatchingVerdictReduction(value, 'critic', 'incomplete_critic') + ); +} + +export function isConfirmValue(value: unknown): value is ConfirmValue { + return ( + isFindingSetValue(value) && + hasInteger(value, 'confirmedCount') && + hasPrivateVerdictDiagnostics(value) && + hasMatchingVerdictReduction(value, 'confirm', 'incomplete_confirm') + ); +} + +export function isCalibrateValue(value: unknown): value is CalibrateValue { + return ( + isFindingSetValue(value) && + hasInteger(value, 'calibratedCount') && + hasPrivateVerdictDiagnostics(value) && + hasMatchingVerdictReduction(value, 'calibrate', 'incomplete_calibrate') + ); +} + +function isBoundedCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= 1_000_000; +} + +// A reduction's shape is closed, not merely a superset check: every isAgenticSastReduction branch +// below calls this so a reduction cannot carry an extra field the schema does not name. Without it, +// something upstream could smuggle unbounded text or a path through a field this validator never +// inspects, since a subset check alone would not catch an addition. +function hasExactKeys(value: Record, required: readonly string[]): boolean { + return Object.keys(value).length === required.length && required.every((key) => key in value); +} + +function hasBoundedCounts(value: Record, fields: readonly string[]): boolean { + return fields.every((field) => isBoundedCount(value[field])); +} + +// 'export' is deliberately excluded: a failed export has no later stage to fall back to, so that +// failure is always the workflow's terminal outcome rather than something recoverable through +// the last-good-findings fallback path. +const FALLBACK_REDUCTION_STAGES = [ + 'architecture', + 'threat-model', + 'plan', + 'research', + 'dedupe', + 'review', + 'critic', + 'confirm', + 'calibrate', +] as const; + +/** Validate one reduction member. New members carry counts only; export keeps bounded omission detail. */ +export function isAgenticSastReduction(value: unknown): value is AgenticSastReduction { + if (!isRecord(value)) return false; + if (value.reason === 'failed_stage_fallback') { + return ( + (FALLBACK_REDUCTION_STAGES as readonly unknown[]).includes(value.stage) && + isBoundedCount(value.fallbackFindingCount) && + hasExactKeys(value, ['stage', 'reason', 'fallbackFindingCount']) + ); + } + if (value.stage === 'architecture') { + return ( + value.reason === 'invalid_architecture_items' && + hasBoundedCounts(value, ['entityCount', 'omittedEntityCount', 'dependencyCount', 'omittedDependencyCount']) && + Number(value.omittedEntityCount) + Number(value.omittedDependencyCount) >= 1 && + Number(value.omittedEntityCount) <= Number(value.entityCount) && + Number(value.omittedDependencyCount) <= Number(value.dependencyCount) && + hasExactKeys(value, [ + 'stage', + 'reason', + 'entityCount', + 'omittedEntityCount', + 'dependencyCount', + 'omittedDependencyCount', + ]) + ); + } + if (value.stage === 'plan') { + return ( + value.reason === 'invalid_investigations' && + hasBoundedCounts(value, ['consideredCount', 'usableCount', 'omittedCount']) && + Number(value.omittedCount) >= 1 && + Number(value.usableCount) + Number(value.omittedCount) === Number(value.consideredCount) && + hasExactKeys(value, ['stage', 'reason', 'consideredCount', 'usableCount', 'omittedCount']) + ); + } + if (value.stage === 'export') { + return ( + value.reason === 'malformed_findings' && + isBoundedCount(value.omittedCount) && + Number(value.omittedCount) >= 1 && + isBoundedCount(value.consideredCount) && + Number(value.consideredCount) >= Number(value.omittedCount) && + Array.isArray(value.omissions) && + value.omissions.length === Number(value.omittedCount) && + value.omissions.every(isAgenticSastOmission) && + hasExactKeys(value, ['stage', 'reason', 'omittedCount', 'consideredCount', 'omissions']) + ); + } + if (value.stage === 'research') { + return ( + value.reason === 'incomplete_research' && + hasBoundedCounts(value, [ + 'triageConsideredCount', + 'triageClassifiedCount', + 'triageOmittedCount', + 'affectedTriageBatchCount', + 'auditUnitCount', + 'salvagedAuditSessionCount', + ]) && + Number(value.triageClassifiedCount) + Number(value.triageOmittedCount) === Number(value.triageConsideredCount) && + Number(value.triageOmittedCount) + Number(value.salvagedAuditSessionCount) >= 1 && + hasExactKeys(value, [ + 'stage', + 'reason', + 'triageConsideredCount', + 'triageClassifiedCount', + 'triageOmittedCount', + 'affectedTriageBatchCount', + 'auditUnitCount', + 'salvagedAuditSessionCount', + ]) + ); + } + if (value.stage === 'dedupe') { + return ( + value.reason === 'incomplete_dedupe' && + hasBoundedCounts(value, ['consideredCount', 'survivorCount', 'unreadableCount', 'salvagedTurnLimitCount']) && + Number(value.unreadableCount) + Number(value.salvagedTurnLimitCount) >= 1 && + Number(value.salvagedTurnLimitCount) <= 1 && + hasExactKeys(value, [ + 'stage', + 'reason', + 'consideredCount', + 'survivorCount', + 'unreadableCount', + 'salvagedTurnLimitCount', + ]) + ); + } + if (['review', 'critic', 'confirm', 'calibrate'].includes(String(value.stage))) { + const stage = value.stage as 'review' | 'critic' | 'confirm' | 'calibrate'; + const expectedReason = `incomplete_${stage}`; + const countFields = [ + 'consideredCount', + 'gradedCount', + 'missingCount', + 'unreadableCount', + 'rejectedUnexpectedCount', + 'rejectedDuplicateCount', + 'salvagedTurnLimitCount', + ]; + if ( + value.reason !== expectedReason || + !hasBoundedCounts(value, countFields) || + Number(value.missingCount) > Number(value.consideredCount) || + Number(value.salvagedTurnLimitCount) > 2 || + Number(value.missingCount) + Number(value.unreadableCount) + Number(value.salvagedTurnLimitCount) < 1 + ) { + return false; + } + if (stage === 'review') { + return ( + isBoundedCount(value.quarantinedCount) && + Number(value.quarantinedCount) <= Number(value.missingCount) && + hasExactKeys(value, ['stage', 'reason', ...countFields, 'quarantinedCount']) + ); + } + return hasExactKeys(value, ['stage', 'reason', ...countFields]); + } + return false; +} + +export function isExportValue(value: unknown): value is ExportValue { + if (!isRecord(value) || !isRecord(value.sarif)) return false; + const reduction = value.reduction; + const reductionIsValid = + reduction === undefined || (isAgenticSastReduction(reduction) && reduction.stage === 'export'); + return ( + isNonEmptyString(value.sarif.path) && + typeof value.sarif.sha256 === 'string' && + /^[0-9a-f]{64}$/.test(value.sarif.sha256) && + hasInteger(value, 'findingCount') && + (value.coverage === 'complete' || value.coverage === 'reduced') && + Array.isArray(value.warnings) && + value.warnings.every((warning) => typeof warning === 'string') && + isNonEmptyString(value.reportPath) && + reductionIsValid + ); +} + +function isAgenticSastOmission(value: unknown): boolean { + if (!isRecord(value)) return false; + if ( + typeof value.reason !== 'string' || + !['invalid_finding_record', 'missing_code_path', 'invalid_code_path'].includes(value.reason) + ) { + return false; + } + const allowedKeys = ['reason']; + if (value.findingId !== undefined) { + if (typeof value.findingId !== 'string' || !/^[a-z0-9-]{1,256}$/.test(value.findingId)) return false; + allowedKeys.push('findingId'); + } + if (value.displayName !== undefined) { + if ( + typeof value.displayName !== 'string' || + value.displayName.length === 0 || + value.displayName.length > 160 || + containsControlCharacter(value.displayName) + ) { + return false; + } + allowedKeys.push('displayName'); + } + return Object.keys(value).length === allowedKeys.length && allowedKeys.every((key) => key in value); +} + +function containsControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127; + }); +} diff --git a/apps/worker/src/ai/sast/sarif-profile.ts b/apps/worker/src/ai/sast/sarif-profile.ts new file mode 100644 index 00000000..c26e7445 --- /dev/null +++ b/apps/worker/src/ai/sast/sarif-profile.ts @@ -0,0 +1,253 @@ +// Copyright (C) 2026 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { isNormalizedRepositoryPath } from './capella/paths.js'; + +/** + * The exact SARIF 2.1.0 profile Capella publishes. The reconciliation SAST + * intake (`ai/reconciliation/sast/sarif-parser.ts`) re-validates the same shape + * on read, so a field added or relaxed here without a matching parser change is + * rejected at intake rather than reconciled. + */ + +export const CAPELLA_SARIF_SCHEMA = + 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json'; +export const CAPELLA_SARIF_DRIVER_NAME = 'Shannon Capella Agentic SAST'; +export const CAPELLA_SARIF_DRIVER_VERSION = '1.0.0'; +export const CAPELLA_SARIF_INFORMATION_URI = 'https://github.com/KeygraphHQ/shannon'; + +export type CapellaSarifSeverity = 'Critical' | 'High' | 'Medium' | 'Low' | 'Info'; +export type CapellaSarifLevel = 'error' | 'warning' | 'note'; + +export interface CapellaSarifPhysicalLocation { + artifactLocation: { uri: string; uriBaseId?: '%SRCROOT%' }; + region: { startLine: number }; +} + +export interface CapellaSarifThreadFlowLocation { + location: { + physicalLocation: CapellaSarifPhysicalLocation; + message: { text: string }; + }; + importance: 'essential' | 'important' | string; +} + +export interface CapellaSarifResult { + ruleId: `CWE-${number}`; + level: CapellaSarifLevel; + message: { text: string }; + locations: [{ physicalLocation: CapellaSarifPhysicalLocation }, ...unknown[]]; + codeFlows: Array<{ threadFlows: Array<{ locations: CapellaSarifThreadFlowLocation[] }> }>; + properties: { + severity: CapellaSarifSeverity; + cwe: `CWE-${number}`; + status: 'verified'; + description: string; + findingSubType: 'AGENT_SAST'; + }; +} + +export interface CapellaSarifRule { + id: `CWE-${number}`; + name: string; + shortDescription: { text: string }; + fullDescription: { text: string }; + helpUri: string; + properties: { cwe: `CWE-${number}`; tags: string[] }; +} + +export interface CapellaSarif { + $schema: string; + version: '2.1.0'; + runs: [ + { + tool: { + driver: { + name: string; + version: string; + informationUri: string; + rules: CapellaSarifRule[]; + }; + }; + results: CapellaSarifResult[]; + properties: { repository: string; totalFindings: number }; + }, + ]; +} + +export interface SarifValidationResult { + readonly valid: boolean; + readonly errors: string[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isText(value: unknown): value is { text: string } { + return isRecord(value) && typeof value.text === 'string' && value.text.length > 0; +} + +function validatePhysicalLocation(value: unknown, errors: string[], label: string, requireBase: boolean): void { + if (!isRecord(value)) { + errors.push(`${label} must be an object`); + return; + } + const artifact = value.artifactLocation; + const region = value.region; + if (!isRecord(artifact) || typeof artifact.uri !== 'string' || !isNormalizedRepositoryPath(artifact.uri)) { + errors.push(`${label}.artifactLocation.uri must be a normalized repository-relative path`); + } + if (requireBase && (!isRecord(artifact) || artifact.uriBaseId !== '%SRCROOT%')) { + errors.push(`${label}.artifactLocation.uriBaseId must be %SRCROOT%`); + } + if (!isRecord(region) || !Number.isSafeInteger(region.startLine) || Number(region.startLine) <= 0) { + errors.push(`${label}.region.startLine must be a positive integer`); + } +} + +function validateResult(value: unknown, ruleIds: Set, errors: string[], index: number): void { + const label = `runs[0].results[${index}]`; + if (!isRecord(value)) { + errors.push(`${label} must be an object`); + return; + } + const ruleId = value.ruleId; + if (typeof ruleId !== 'string' || !/^CWE-\d+$/.test(ruleId)) errors.push(`${label}.ruleId must be a bare CWE`); + if (typeof ruleId === 'string' && !ruleIds.has(ruleId)) errors.push(`${label}.ruleId has no matching rule metadata`); + if (!['error', 'warning', 'note'].includes(String(value.level))) errors.push(`${label}.level is invalid`); + if (!isText(value.message)) errors.push(`${label}.message.text is required`); + if (!Array.isArray(value.locations) || value.locations.length === 0 || !isRecord(value.locations[0])) { + errors.push(`${label}.locations[0] is required`); + } else { + validatePhysicalLocation( + value.locations[0].physicalLocation, + errors, + `${label}.locations[0].physicalLocation`, + true, + ); + } + const properties = value.properties; + if (!isRecord(properties)) { + errors.push(`${label}.properties is required`); + } else { + if (!['Critical', 'High', 'Medium', 'Low', 'Info'].includes(String(properties.severity))) { + errors.push(`${label}.properties.severity is invalid`); + } + if (properties.cwe !== ruleId) errors.push(`${label}.properties.cwe must equal ruleId`); + if (properties.status !== 'verified') errors.push(`${label}.properties.status must be verified`); + if (properties.findingSubType !== 'AGENT_SAST') errors.push(`${label}.properties.findingSubType is invalid`); + if (typeof properties.description !== 'string') errors.push(`${label}.properties.description must be a string`); + // The intake parser rejects results carrying these properties; refusing them + // at publish time surfaces the violation to the producer instead of dropping + // findings at intake. + for (const forbidden of ['invariantDescription', 'owasp_category', 'proofOfConcept']) { + if (forbidden in properties) errors.push(`${label}.properties.${forbidden} is forbidden`); + } + } + if (!Array.isArray(value.codeFlows)) { + errors.push(`${label}.codeFlows must be an array`); + } else { + value.codeFlows.forEach((flow, flowIndex) => { + if (!isRecord(flow) || !Array.isArray(flow.threadFlows)) { + errors.push(`${label}.codeFlows[${flowIndex}].threadFlows must be an array`); + return; + } + flow.threadFlows.forEach((thread, threadIndex) => { + if (!isRecord(thread) || !Array.isArray(thread.locations)) { + errors.push(`${label}.codeFlows[${flowIndex}].threadFlows[${threadIndex}].locations must be an array`); + return; + } + thread.locations.forEach((location, locationIndex) => { + if (!isRecord(location) || !isRecord(location.location)) { + errors.push(`${label}.codeFlows location must be an object`); + return; + } + validatePhysicalLocation( + location.location.physicalLocation, + errors, + `${label}.codeFlows[${flowIndex}].threadFlows[${threadIndex}].locations[${locationIndex}]`, + false, + ); + if (!isText(location.location.message)) errors.push(`${label}.codeFlows location message is required`); + if (typeof location.importance !== 'string') + errors.push(`${label}.codeFlows location importance is required`); + }); + }); + }); + } +} + +/** + * Validate a document against the exact producer/consumer profile above. The + * exporter refuses to publish a document this rejects, which is what lets the + * reconciliation intake treat a violation as corruption instead of noise. + */ +export function validateCapellaSarif(value: unknown): SarifValidationResult { + const errors: string[] = []; + if (!isRecord(value)) return { valid: false, errors: ['SARIF document must be an object'] }; + if (value.$schema !== CAPELLA_SARIF_SCHEMA) errors.push('$schema is invalid'); + if (value.version !== '2.1.0') errors.push('version must be 2.1.0'); + if (!Array.isArray(value.runs) || value.runs.length !== 1 || !isRecord(value.runs[0])) { + errors.push('runs must contain exactly one run'); + return { valid: false, errors }; + } + const run = value.runs[0]; + const driver = isRecord(run.tool) && isRecord(run.tool.driver) ? run.tool.driver : undefined; + if (!driver) { + errors.push('runs[0].tool.driver is required'); + return { valid: false, errors }; + } + if (driver.name !== CAPELLA_SARIF_DRIVER_NAME) errors.push('driver.name is invalid'); + if (driver.version !== CAPELLA_SARIF_DRIVER_VERSION) errors.push('driver.version is invalid'); + if (driver.informationUri !== CAPELLA_SARIF_INFORMATION_URI) errors.push('driver.informationUri is invalid'); + const ruleIds = new Set(); + if (!Array.isArray(driver.rules)) { + errors.push('driver.rules must be an array'); + } else { + driver.rules.forEach((rule, index) => { + if (!isRecord(rule) || typeof rule.id !== 'string' || !/^CWE-\d+$/.test(rule.id)) { + errors.push(`driver.rules[${index}].id must be a bare CWE`); + return; + } + if (ruleIds.has(rule.id)) errors.push(`driver.rules[${index}].id is duplicated`); + ruleIds.add(rule.id); + if (typeof rule.name !== 'string' || rule.name.length === 0) + errors.push(`driver.rules[${index}].name is required`); + if (!isText(rule.shortDescription) || !isText(rule.fullDescription)) + errors.push(`driver.rules[${index}] descriptions are required`); + if (typeof rule.helpUri !== 'string' || rule.helpUri.length === 0) { + errors.push(`driver.rules[${index}].helpUri is required`); + } + if ( + !isRecord(rule.properties) || + rule.properties.cwe !== rule.id || + !Array.isArray(rule.properties.tags) || + !rule.properties.tags.every((tag) => typeof tag === 'string') + ) { + errors.push(`driver.rules[${index}] properties are invalid`); + } + }); + } + if (!Array.isArray(run.results)) { + errors.push('runs[0].results must be an array'); + } else { + run.results.forEach((result, index) => { + validateResult(result, ruleIds, errors, index); + }); + } + if (!isRecord(run.properties)) { + errors.push('runs[0].properties is required'); + } else { + if (typeof run.properties.repository !== 'string') errors.push('runs[0].properties.repository must be a string'); + if (!Number.isSafeInteger(run.properties.totalFindings)) { + errors.push('runs[0].properties.totalFindings must be an integer'); + } else if (Array.isArray(run.results) && run.properties.totalFindings !== run.results.length) { + errors.push('runs[0].properties.totalFindings does not match results.length'); + } + } + return { valid: errors.length === 0, errors }; +} diff --git a/apps/worker/src/ai/sast/types.ts b/apps/worker/src/ai/sast/types.ts new file mode 100644 index 00000000..0e4bf645 --- /dev/null +++ b/apps/worker/src/ai/sast/types.ts @@ -0,0 +1,183 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** Shared neutral contracts for Capella execution and SARIF handoff. */ + +export interface SarifRef { + path: string; + sha256: string; +} + +export type CapellaStage = + | 'architecture' + | 'threat-model' + | 'plan' + | 'research' + | 'dedupe' + | 'review' + | 'critic' + | 'confirm' + | 'calibrate' + | 'export'; + +export type CapellaFailurePoint = CapellaStage | 'workflow'; + +export interface CapellaUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + costUsd: number; + turns: number; +} + +/** Architecture-stage reduction: malformed model-authored KB items were dropped. */ +export interface AgenticSastArchitectureReduction { + readonly stage: 'architecture'; + readonly reason: 'invalid_architecture_items'; + readonly entityCount: number; + readonly omittedEntityCount: number; + readonly dependencyCount: number; + readonly omittedDependencyCount: number; +} + +/** Plan-stage reduction: malformed investigations were dropped before research. */ +export interface AgenticSastPlanReduction { + readonly stage: 'plan'; + readonly reason: 'invalid_investigations'; + readonly consideredCount: number; + readonly usableCount: number; + readonly omittedCount: number; +} + +/** Aggregate research reduction across triage and deep-audit units. */ +export interface AgenticSastResearchReduction { + readonly stage: 'research'; + readonly reason: 'incomplete_research'; + readonly triageConsideredCount: number; + readonly triageClassifiedCount: number; + readonly triageOmittedCount: number; + readonly affectedTriageBatchCount: number; + readonly auditUnitCount: number; + readonly salvagedAuditSessionCount: number; +} + +/** Dedupe-stage reduction: invalid files or a salvaged turn-limit reduced coverage. */ +export interface AgenticSastDedupeReduction { + readonly stage: 'dedupe'; + readonly reason: 'incomplete_dedupe'; + readonly consideredCount: number; + readonly survivorCount: number; + readonly unreadableCount: number; + readonly salvagedTurnLimitCount: number; +} + +interface AgenticSastVerdictReductionBase { + readonly consideredCount: number; + readonly gradedCount: number; + readonly missingCount: number; + readonly unreadableCount: number; + readonly rejectedUnexpectedCount: number; + readonly rejectedDuplicateCount: number; + readonly salvagedTurnLimitCount: number; +} + +/** Review-stage reduction. Ungraded survivors are quarantined before publication. */ +export interface AgenticSastReviewReduction extends AgenticSastVerdictReductionBase { + readonly stage: 'review'; + readonly reason: 'incomplete_review'; + readonly quarantinedCount: number; +} + +export interface AgenticSastCriticReduction extends AgenticSastVerdictReductionBase { + readonly stage: 'critic'; + readonly reason: 'incomplete_critic'; +} + +export interface AgenticSastConfirmReduction extends AgenticSastVerdictReductionBase { + readonly stage: 'confirm'; + readonly reason: 'incomplete_confirm'; +} + +export interface AgenticSastCalibrateReduction extends AgenticSastVerdictReductionBase { + readonly stage: 'calibrate'; + readonly reason: 'incomplete_calibrate'; +} + +export type CapellaFallbackStage = Exclude; + +/** A failed stage completed from the last verified finding artifact instead. */ +export interface AgenticSastFallbackReduction { + readonly stage: CapellaFallbackStage; + readonly reason: 'failed_stage_fallback'; + readonly fallbackFindingCount: number; +} + +/** Export-stage reduction: findings dropped because their records were malformed. */ +export interface AgenticSastExportReduction { + readonly stage: 'export'; + readonly reason: 'malformed_findings'; + readonly omittedCount: number; + readonly consideredCount: number; + readonly omissions: readonly AgenticSastOmission[]; +} + +/** + * One reduced-coverage fact. A run carries at most one member per stage, in stage order. New + * reductions project bounded counts only. The pre-existing export reduction is the intentional + * exception: it retains bounded, sanitized omission identity and display-name details. + */ +export type AgenticSastReduction = + | AgenticSastArchitectureReduction + | AgenticSastPlanReduction + | AgenticSastResearchReduction + | AgenticSastDedupeReduction + | AgenticSastReviewReduction + | AgenticSastCriticReduction + | AgenticSastConfirmReduction + | AgenticSastCalibrateReduction + | AgenticSastFallbackReduction + | AgenticSastExportReduction; + +export interface AgenticSastOmission { + readonly findingId?: string; + readonly displayName?: string; + readonly reason: 'invalid_finding_record' | 'missing_code_path' | 'invalid_code_path'; +} + +/** Original bounded failure retained when the child finishes from a last-good artifact. */ +export interface CapellaRecoveredFailure { + readonly failedStage: CapellaFallbackStage; + readonly error: string; + readonly errorCode?: string; + readonly completedStages: readonly CapellaStage[]; +} + +export type CapellaRunResult = + | { + status: 'succeeded'; + sarif: SarifRef; + findingCount: number; + coverage: 'complete' | 'reduced'; + durationMs: number; + usage: CapellaUsage; + usageComplete: boolean; + warnings: string[]; + reductions?: readonly AgenticSastReduction[]; + recoveredFailure?: CapellaRecoveredFailure; + } + | { + status: 'failed'; + failedStage: CapellaFailurePoint; + error: string; + /** Bounded machine code from the failing activity's classified failure, when available. */ + errorCode?: string; + durationMs: number; + usage: CapellaUsage; + usageComplete: boolean; + completedStages: CapellaStage[]; + warnings: string[]; + }; diff --git a/apps/worker/src/ai/structured-generation.ts b/apps/worker/src/ai/structured-generation.ts new file mode 100644 index 00000000..cb4907af --- /dev/null +++ b/apps/worker/src/ai/structured-generation.ts @@ -0,0 +1,35 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** One structured-generation request with exactly one output-schema tool. */ +export interface StructuredGenerationRequest { + systemPrompt?: string; + userContent: string; + tool: { + name: 'submit_result'; + description: string; + parametersJsonSchema: Record; + }; + maxTokens: number; + signal?: AbortSignal; +} + +/** Host-neutral outcome of one structured generation request. */ +export interface StructuredGenerationResult { + stopReason: 'toolUse' | 'stop' | 'length' | 'error' | 'aborted'; + toolCalls: Array<{ name: string; arguments: unknown }>; + usage: { + inputTokens: number; + outputTokens: number; + costUsd: number; + }; + errorMessage?: string; +} + +/** Host-supplied transport that makes exactly one model request per call. */ +export interface StructuredGenerationPort { + generate(request: StructuredGenerationRequest, modelContext: TModelContext): Promise; +} diff --git a/apps/worker/src/services/error-handling.ts b/apps/worker/src/services/error-handling.ts index b808b65a..de89d5a6 100644 --- a/apps/worker/src/services/error-handling.ts +++ b/apps/worker/src/services/error-handling.ts @@ -4,8 +4,52 @@ // it under the terms of the GNU Affero General Public License version 3 // as published by the Free Software Foundation. -import { type AssistantMessage, isRetryableAssistantError } from '@earendil-works/pi-ai'; -import { ErrorCode, type PentestErrorContext, type PentestErrorType, type PromptErrorResult } from '../types/errors.js'; +import { type AssistantMessage, isContextOverflow, isRetryableAssistantError } from '@earendil-works/pi-ai'; +import { + ErrorCode, + type PentestErrorContext, + type PentestErrorType, + type PromptErrorResult, + type ProviderFailure, + type ProviderFailureCategory, +} from '../types/errors.js'; + +// The provider boundary answers two independent questions and never lets one decide the other: +// +// - Retryability: no Shannon-owned parser decides this. A typed PentestError keeps its own +// verdict, context overflow is terminal, a genuinely-thrown SDK error with structured +// status/headers mirrors pi's request-layer policy, and every other (flattened) failure +// defers to pi's own isRetryableAssistantError helper. +// - Category: an observational label emitted only from reliable positive evidence — a preserved +// category, a typed PentestError, the context-overflow check, or structured status. It is +// never guessed from free text and never derived from the retry boolean. +// +// The matched provider text is always discarded and replaced with the fixed +// PROVIDER_FAILURE_MESSAGES entry, so raw provider responses never reach durable state or output. + +// Node system error codes for transient transport faults. These are structured fields on a +// genuinely-thrown error, not provider prose, so reading them is not the text-parsing the boundary +// avoids. pi's flattened-message helper recognizes the prose forms ("connection refused", "fetch +// failed") but not these raw codes, so a thrown ECONNRESET would otherwise fail closed as terminal. +const NODE_TRANSPORT_ERROR_CODES: ReadonlySet = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EPIPE', + 'EAI_AGAIN', + 'ENOTFOUND', +]); + +const PROVIDER_FAILURE_MESSAGES: Readonly> = { + rate_limit: 'The provider rate-limited the model request.', + overloaded: 'The provider was temporarily overloaded.', + transport: 'The provider request failed because of a transient transport error.', + context_limit: 'The model request exceeded the provider context limit.', + quota: 'The provider quota is exhausted.', + authentication: 'Provider authentication failed. Verify the configured credential.', + configuration: 'Provider configuration is invalid. Verify the selected provider, model, and endpoint.', + unknown: 'The provider rejected the model request with a non-retryable error.', +} as const; export class PentestError extends Error { override name = 'PentestError' as const; @@ -15,6 +59,8 @@ export class PentestError extends Error { timestamp: string; /** Optional specific error code for reliable classification */ code?: ErrorCode; + /** Sanitized provider category, preserved across boundaries so it is not reclassified and degraded. */ + providerCategory?: ProviderFailureCategory; constructor( message: string, @@ -47,20 +93,204 @@ export function handlePromptError(promptName: string, error: Error): PromptError /** * Whether a failed agent attempt is worth retrying. * - * A PentestError already carries a verdict — for provider turns that verdict - * comes from pi — so it is taken as given. Anything else is raw text, judged by - * pi's classifier: transient for load, throttling, and transport failures, - * terminal for quota, billing, and auth. Unrecognised errors are not retried, so - * a permanent fault fails fast. + * Uniform across every caller: the same retry rule decides preflight, ordinary agents, + * task formation, SAST enrichment, and Capella. No provider text is parsed here. */ -export function isRetryableFailure(error: Error): boolean { - if (error instanceof PentestError) return error.retryable; +export function isRetryableFailure(error: unknown): boolean { + return isProviderRetryable(error); +} - return isRetryableAssistantError({ - role: 'assistant', - stopReason: 'error', - errorMessage: error.message, - } as AssistantMessage); +function providerFailure( + category: ProviderFailureCategory, + retryable: boolean, + type: ProviderFailure['type'], +): ProviderFailure { + return { type, category, retryable, message: PROVIDER_FAILURE_MESSAGES[category] }; +} + +function providerFailureText(error: unknown): string { + if ( + typeof error === 'object' && + error !== null && + 'errorMessage' in error && + typeof error.errorMessage === 'string' + ) { + return error.errorMessage; + } + return error instanceof Error ? error.message : String(error); +} + +function objectRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function parseProviderStatus(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599) return value; + if (typeof value === 'string' && /^[1-5]\d{2}$/u.test(value.trim())) return Number(value.trim()); + return undefined; +} + +/** Read only conventional bounded status fields; never stringify provider objects for classification. */ +function structuredProviderStatus(error: unknown): number | undefined { + const record = objectRecord(error); + if (!record) return undefined; + const response = objectRecord(record.response); + const metadata = objectRecord(record.$metadata); + const candidates = [ + record.status, + record.statusCode, + record.status_code, + response?.status, + response?.statusCode, + metadata?.httpStatusCode, + ]; + for (const candidate of candidates) { + const status = parseProviderStatus(candidate); + if (status !== undefined) return status; + } + return undefined; +} + +/** + * An explicit x-should-retry verdict from a genuinely-thrown SDK error's headers, matching the + * header pi honors in its own request-layer retry loop. Only present on real thrown errors. + */ +function structuredRetryHeader(error: unknown): boolean | undefined { + const record = objectRecord(error); + if (!record) return undefined; + const headers = record.headers; + let raw: unknown; + if (headers != null && typeof (headers as { get?: unknown }).get === 'function') { + raw = (headers as { get: (name: string) => unknown }).get('x-should-retry'); + } else { + raw = objectRecord(headers)?.['x-should-retry']; + } + if (raw === undefined || raw === null) return undefined; + const value = String(raw).trim().toLowerCase(); + if (value === 'true') return true; + if (value === 'false') return false; + return undefined; +} + +/** + * Shape a failure into the AssistantMessage pi's helpers expect. A real assistant message is + * passed through unchanged so pi can still detect silent overflow from its usage; anything else + * is wrapped as an errored turn carrying only its already-derived text. + */ +function asAssistantMessage(error: unknown, text: string): AssistantMessage { + const record = objectRecord(error); + if (record && 'stopReason' in record && typeof record.errorMessage === 'string') { + return error as AssistantMessage; + } + return { role: 'assistant', stopReason: 'error', errorMessage: text } as AssistantMessage; +} + +function isProviderOverflow(error: unknown, text: string, contextWindow?: number): boolean { + return isContextOverflow(asAssistantMessage(error, text), contextWindow); +} + +/** Structured HTTP status → observational category. Mirrors pi's request-layer status semantics. */ +function categoryForProviderStatus(status: number): ProviderFailureCategory | undefined { + if (status === 401 || status === 403) return 'authentication'; + if (status === 413) return 'context_limit'; + if (status === 429) return 'rate_limit'; + if (status === 408 || status === 409) return 'transport'; + if (status >= 500) return 'overloaded'; + return undefined; +} + +/** Whether a structured status should retry, mirroring pi's request-layer policy. */ +function isRetryableProviderStatus(status: number): boolean { + return status === 408 || status === 409 || status === 429 || status >= 500; +} + +/** A transient transport fault identified by a Node system error code on the error or its cause. */ +function structuredTransportCode(error: unknown): boolean { + const record = objectRecord(error); + if (!record) return false; + if (typeof record.code === 'string' && NODE_TRANSPORT_ERROR_CODES.has(record.code)) return true; + const causeCode = objectRecord(record.cause)?.code; + return typeof causeCode === 'string' && NODE_TRANSPORT_ERROR_CODES.has(causeCode); +} + +/** + * Retryability of a failed attempt, computed independently of the category. Typed errors keep + * their verdict; context overflow is terminal; structured status/headers mirror pi's request + * layer; every flattened failure defers to pi's own helper. Nothing else parses provider text. + */ +export function isProviderRetryable(error: unknown, contextWindow?: number): boolean { + if (error instanceof PentestError) { + if (error.code !== undefined) return classifyByErrorCode(error.code, error.retryable).retryable; + return error.retryable; + } + const text = providerFailureText(error); + if (isProviderOverflow(error, text, contextWindow)) return false; + const header = structuredRetryHeader(error); + if (header !== undefined) return header; + const status = structuredProviderStatus(error); + if (status !== undefined) return isRetryableProviderStatus(status); + if (structuredTransportCode(error)) return true; + return isRetryableAssistantError(asAssistantMessage(error, text)); +} + +/** Error type of a provider failure. Only a typed PentestError may be auth/config; raw messages never are. */ +function providerType(error: unknown): ProviderFailure['type'] { + if (error instanceof PentestError && error.code !== undefined) { + const classified = classifyByErrorCode(error.code, error.retryable); + if (classified.type === 'AuthenticationError') return 'AuthenticationError'; + if (classified.type === 'ConfigurationError') return 'ConfigurationError'; + } + return 'AgentExecutionError'; +} + +/** + * Observational category, computed independently of retryability. Positive evidence only: a + * preserved category, a typed PentestError, the context-overflow check, or structured status. + * A flattened failure with no such evidence is honestly `unknown`, never a guess. + */ +export function providerCategory(error: unknown, contextWindow?: number): ProviderFailureCategory { + if (error instanceof PentestError) { + if (error.providerCategory !== undefined) return error.providerCategory; + if (error.code !== undefined) { + const classified = classifyByErrorCode(error.code, error.retryable); + if (classified.type === 'AuthenticationError') return 'authentication'; + if (classified.type === 'ConfigurationError') return 'configuration'; + } + return 'unknown'; + } + const text = providerFailureText(error); + if (isProviderOverflow(error, text, contextWindow)) return 'context_limit'; + const status = structuredProviderStatus(error); + if (status !== undefined) { + const category = categoryForProviderStatus(status); + if (category !== undefined) return category; + } + if (structuredTransportCode(error)) return 'transport'; + return 'unknown'; +} + +/** Classify a provider failure without carrying provider response text across the boundary. */ +export function classifyProviderFailure(error: unknown, contextWindow?: number): ProviderFailure { + return providerFailure( + providerCategory(error, contextWindow), + isProviderRetryable(error, contextWindow), + providerType(error), + ); +} + +/** + * Bounded machine code for a classified provider failure. It is derived only from the + * classification, so nothing the provider wrote can reach a stored or logged message. + */ +export function providerFailureCode(failure: ProviderFailure): string { + return failure.category; +} + +/** The one sentence a rejected model request produces, carrying only its bounded code. */ +export function providerFailureSentence(failure: ProviderFailure): string { + return `The model provider rejected the request (${providerFailureCode(failure)}).`; } /** @@ -123,7 +353,8 @@ function classifyByErrorCode(code: ErrorCode, retryableFromError: boolean): { ty * * Classification priority: * 1. A PentestError carrying an ErrorCode is classified by that code. - * 2. Anything else falls through to isRetryableFailure. + * 2. Anything else goes through the bounded provider classifier. The original value is passed + * through unchanged so an AssistantMessage keeps the fields the classifier reads. */ export function classifyErrorForTemporal(error: unknown): { type: string; retryable: boolean } { // === CODE-BASED CLASSIFICATION (Preferred for internal errors) === @@ -132,10 +363,12 @@ export function classifyErrorForTemporal(error: unknown): { type: string; retrya } // === FALLBACK === - // Everything else is a raw throw: a library error, or a PentestError carrying no - // code. isRetryableFailure decides — pi's classifier for provider text, the - // error's own verdict when it has one, and no retry for anything unrecognised. - const err = error instanceof Error ? error : new Error(String(error)); - const retryable = isRetryableFailure(err); - return { type: retryable ? 'TransientError' : 'PermanentError', retryable }; + // Credential and configuration failures must surface under their own names and never retry: + // retrying them as transient would burn the whole retry budget on a failure the operator has + // to fix. Everything else becomes a Transient/Permanent marker from the retry verdict. + const failure = classifyProviderFailure(error); + if (failure.type === 'AuthenticationError' || failure.type === 'ConfigurationError') { + return { type: failure.type, retryable: failure.retryable }; + } + return { type: failure.retryable ? 'TransientError' : 'PermanentError', retryable: failure.retryable }; } diff --git a/apps/worker/src/types/errors.ts b/apps/worker/src/types/errors.ts index 5c0fa7c1..5478684e 100644 --- a/apps/worker/src/types/errors.ts +++ b/apps/worker/src/types/errors.ts @@ -44,6 +44,31 @@ export enum ErrorCode { export type PentestErrorType = 'config' | 'network' | 'prompt' | 'filesystem' | 'validation' | 'unknown'; +/** Stable, sanitized provider failure passed across model execution boundaries. */ +export const PROVIDER_FAILURE_CATEGORIES = Object.freeze([ + 'rate_limit', + 'overloaded', + 'transport', + 'context_limit', + 'quota', + 'authentication', + 'configuration', + 'unknown', +] as const); + +export type ProviderFailureCategory = (typeof PROVIDER_FAILURE_CATEGORIES)[number]; + +export function isProviderFailureCategory(value: unknown): value is ProviderFailureCategory { + return typeof value === 'string' && PROVIDER_FAILURE_CATEGORIES.includes(value as ProviderFailureCategory); +} + +export interface ProviderFailure { + readonly type: 'AuthenticationError' | 'ConfigurationError' | 'AgentExecutionError'; + readonly category: ProviderFailureCategory; + readonly retryable: boolean; + readonly message: string; +} + export interface PentestErrorContext { [key: string]: unknown; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b7be47f..e547ee22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: specifier: ^1.1.0 version: 1.1.0 '@temporalio/client': - specifier: ^1.11.0 + specifier: 1.15.0 version: 1.15.0 chokidar: specifier: ^5.0.0 @@ -58,16 +58,16 @@ importers: specifier: ^10.9.0 version: 10.9.0(@earendil-works/pi-coding-agent@0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.21.0)(zod@4.3.6))(@earendil-works/pi-tui@0.82.1) '@temporalio/activity': - specifier: ^1.11.0 + specifier: 1.15.0 version: 1.15.0 '@temporalio/client': - specifier: ^1.11.0 + specifier: 1.15.0 version: 1.15.0 '@temporalio/worker': - specifier: ^1.11.0 + specifier: 1.15.0 version: 1.15.0(tslib@2.8.1) '@temporalio/workflow': - specifier: ^1.11.0 + specifier: 1.15.0 version: 1.15.0 ajv: specifier: ^8.12.0 @@ -78,6 +78,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.6.1 + handlebars: + specifier: ^4.7.9 + version: 4.7.9 js-yaml: specifier: ^4.1.0 version: 4.1.1 @@ -1376,6 +1379,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1560,6 +1568,9 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -2008,6 +2019,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} @@ -2079,6 +2095,9 @@ packages: engines: {node: '>= 8'} hasBin: true + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3598,6 +3617,15 @@ snapshots: graceful-fs@4.2.11: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@4.0.0: {} has-symbols@1.1.0: @@ -3775,6 +3803,8 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minimist@1.2.8: {} + minipass@7.1.3: {} ms@2.1.3: {} @@ -4214,6 +4244,9 @@ snapshots: typescript@5.9.3: {} + uglify-js@3.19.3: + optional: true + unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 @@ -4292,6 +4325,8 @@ snapshots: dependencies: isexe: 2.0.0 + wordwrap@1.0.0: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 From c33132b0ab56070e0e8f7db3a112b57019d9fb7a Mon Sep 17 00:00:00 2001 From: ajmallesh Date: Wed, 26 Aug 2026 19:37:20 -0700 Subject: [PATCH 02/11] feat(worker): deduplicate static and runtime findings before exploitation Parse Agentic SAST SARIF into typed observations, enrich and route those observations, and reconcile them with pentest findings before exploitation. Publish deterministic exploitation queues with stable lineage, exact-path Git commits, retry-safe manifests, named drop reasons, and confined task formation. Reject duplicate producer IDs before commit and adopt either legal provenance shape after a lost acknowledgement. --- apps/worker/prompts/sast-enrichment-auth.txt | 13 + apps/worker/prompts/sast-enrichment-authz.txt | 12 + .../prompts/sast-enrichment-injection.txt | 16 + .../prompts/sast-enrichment-miscellaneous.txt | 14 + apps/worker/prompts/sast-enrichment-ssrf.txt | 11 + apps/worker/prompts/sast-enrichment-xss.txt | 11 + .../_sast-enrichment-procedure.txt | 7 + .../_task-formation-procedure.txt | 29 + apps/worker/prompts/task-formation-auth.txt | 11 + apps/worker/prompts/task-formation-authz.txt | 11 + .../prompts/task-formation-injection.txt | 11 + .../prompts/task-formation-miscellaneous.txt | 11 + apps/worker/prompts/task-formation-ssrf.txt | 11 + apps/worker/prompts/task-formation-xss.txt | 11 + apps/worker/src/ai/pi/source-jail.ts | 279 +++++++ .../worker/src/ai/pi/structured-generation.ts | 31 +- .../src/ai/pi/task-formation-executor.ts | 786 ++++++++++++++++++ apps/worker/src/ai/queue-schemas.ts | 198 ++++- .../src/ai/reconciliation/artifact-store.ts | 518 ++++++++++++ .../worker/src/ai/reconciliation/contracts.ts | 131 +++ apps/worker/src/ai/reconciliation/enrich.ts | 415 +++++++++ apps/worker/src/ai/reconciliation/form.ts | 432 ++++++++++ apps/worker/src/ai/reconciliation/labels.ts | 47 ++ apps/worker/src/ai/reconciliation/manifest.ts | 230 +++++ .../src/ai/reconciliation/materialize-core.ts | 179 ++++ .../src/ai/reconciliation/materialize.ts | 213 +++++ .../src/ai/reconciliation/observation-view.ts | 109 +++ .../src/ai/reconciliation/observations.ts | 20 + apps/worker/src/ai/reconciliation/prepare.ts | 379 +++++++++ apps/worker/src/ai/reconciliation/publish.ts | 647 ++++++++++++++ apps/worker/src/ai/reconciliation/refs.ts | 50 ++ .../reconciliation/sast/context-extractor.ts | 82 ++ .../src/ai/reconciliation/sast/cwe-mapper.ts | 103 +++ .../reconciliation/sast/enrichment/batch.ts | 86 ++ .../reconciliation/sast/enrichment/policy.ts | 53 ++ .../reconciliation/sast/enrichment/schema.ts | 61 ++ .../sast/enrichment/validate.ts | 205 +++++ .../src/ai/reconciliation/sast/intake.ts | 132 +++ .../ai/reconciliation/sast/sarif-parser.ts | 270 ++++++ .../src/ai/reconciliation/sast/types.ts | 132 +++ .../src/ai/reconciliation/schema-version.ts | 7 + .../ai/reconciliation/seed-miscellaneous.ts | 205 +++++ .../src/ai/reconciliation/stage-contracts.ts | 160 ++++ .../ai/reconciliation/submit-validation.ts | 118 +++ .../reconciliation/task-formation-schema.ts | 209 +++++ .../src/collectors/exploit-collector.ts | 85 +- apps/worker/src/services/exploit-renderer.ts | 9 +- apps/worker/src/services/git-manager.ts | 299 ++++++- apps/worker/src/services/queue-validation.ts | 124 ++- .../src/temporal/reconcile-activities.ts | 542 ++++++++++++ .../src/temporal/reconcile-activity-types.ts | 246 ++++++ apps/worker/src/types/reconciliation.ts | 27 + 52 files changed, 7914 insertions(+), 84 deletions(-) create mode 100644 apps/worker/prompts/sast-enrichment-auth.txt create mode 100644 apps/worker/prompts/sast-enrichment-authz.txt create mode 100644 apps/worker/prompts/sast-enrichment-injection.txt create mode 100644 apps/worker/prompts/sast-enrichment-miscellaneous.txt create mode 100644 apps/worker/prompts/sast-enrichment-ssrf.txt create mode 100644 apps/worker/prompts/sast-enrichment-xss.txt create mode 100644 apps/worker/prompts/shared/exploitation/_sast-enrichment-procedure.txt create mode 100644 apps/worker/prompts/shared/exploitation/_task-formation-procedure.txt create mode 100644 apps/worker/prompts/task-formation-auth.txt create mode 100644 apps/worker/prompts/task-formation-authz.txt create mode 100644 apps/worker/prompts/task-formation-injection.txt create mode 100644 apps/worker/prompts/task-formation-miscellaneous.txt create mode 100644 apps/worker/prompts/task-formation-ssrf.txt create mode 100644 apps/worker/prompts/task-formation-xss.txt create mode 100644 apps/worker/src/ai/pi/source-jail.ts create mode 100644 apps/worker/src/ai/pi/task-formation-executor.ts create mode 100644 apps/worker/src/ai/reconciliation/artifact-store.ts create mode 100644 apps/worker/src/ai/reconciliation/contracts.ts create mode 100644 apps/worker/src/ai/reconciliation/enrich.ts create mode 100644 apps/worker/src/ai/reconciliation/form.ts create mode 100644 apps/worker/src/ai/reconciliation/labels.ts create mode 100644 apps/worker/src/ai/reconciliation/manifest.ts create mode 100644 apps/worker/src/ai/reconciliation/materialize-core.ts create mode 100644 apps/worker/src/ai/reconciliation/materialize.ts create mode 100644 apps/worker/src/ai/reconciliation/observation-view.ts create mode 100644 apps/worker/src/ai/reconciliation/observations.ts create mode 100644 apps/worker/src/ai/reconciliation/prepare.ts create mode 100644 apps/worker/src/ai/reconciliation/publish.ts create mode 100644 apps/worker/src/ai/reconciliation/refs.ts create mode 100644 apps/worker/src/ai/reconciliation/sast/context-extractor.ts create mode 100644 apps/worker/src/ai/reconciliation/sast/cwe-mapper.ts create mode 100644 apps/worker/src/ai/reconciliation/sast/enrichment/batch.ts create mode 100644 apps/worker/src/ai/reconciliation/sast/enrichment/policy.ts create mode 100644 apps/worker/src/ai/reconciliation/sast/enrichment/schema.ts create mode 100644 apps/worker/src/ai/reconciliation/sast/enrichment/validate.ts create mode 100644 apps/worker/src/ai/reconciliation/sast/intake.ts create mode 100644 apps/worker/src/ai/reconciliation/sast/sarif-parser.ts create mode 100644 apps/worker/src/ai/reconciliation/sast/types.ts create mode 100644 apps/worker/src/ai/reconciliation/schema-version.ts create mode 100644 apps/worker/src/ai/reconciliation/seed-miscellaneous.ts create mode 100644 apps/worker/src/ai/reconciliation/stage-contracts.ts create mode 100644 apps/worker/src/ai/reconciliation/submit-validation.ts create mode 100644 apps/worker/src/ai/reconciliation/task-formation-schema.ts create mode 100644 apps/worker/src/temporal/reconcile-activities.ts create mode 100644 apps/worker/src/temporal/reconcile-activity-types.ts create mode 100644 apps/worker/src/types/reconciliation.ts diff --git a/apps/worker/prompts/sast-enrichment-auth.txt b/apps/worker/prompts/sast-enrichment-auth.txt new file mode 100644 index 00000000..ece5df4b --- /dev/null +++ b/apps/worker/prompts/sast-enrichment-auth.txt @@ -0,0 +1,13 @@ +@include(shared/exploitation/_sast-enrichment-procedure.txt) + +These findings are authentication vulnerabilities. + +CRITICAL RULES: +- exploitation_hypothesis must describe what an attacker ACHIEVES, not just confirm the vulnerability exists. +- suggested_exploit_technique must be an actionable attack the exploitation agent can execute against a live application. +- source_endpoint: infer the HTTP method and path from the code context (route definitions, handler functions). +- For hard-coded credentials (CWE-798): exploitation_hypothesis should specify using the found credentials. +- For CSRF (CWE-352): include the state-changing action that can be forged. +- _sastId MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it. + +SAST FINDINGS: diff --git a/apps/worker/prompts/sast-enrichment-authz.txt b/apps/worker/prompts/sast-enrichment-authz.txt new file mode 100644 index 00000000..7618f1aa --- /dev/null +++ b/apps/worker/prompts/sast-enrichment-authz.txt @@ -0,0 +1,12 @@ +@include(shared/exploitation/_sast-enrichment-procedure.txt) + +These findings are authorization vulnerabilities. + +CRITICAL RULES: +- Horizontal: same role accessing another user's data. Vertical: lower role accessing higher role's functions. Context_Workflow: bypassing a required step/state. Mass_Assignment: adding privileged fields (role, isAdmin, permissions) to request body that the server binds without filtering. +- If a proof-of-concept exists in the SAST data, use its inputs to craft a specific minimal_witness. +- guard_evidence must describe what's MISSING, not what exists. +- side_effect must be a concrete unauthorized action (e.g., "read other user's medical records"), not vague ("unauthorized access"). +- _sastId MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it. + +SAST FINDINGS: diff --git a/apps/worker/prompts/sast-enrichment-injection.txt b/apps/worker/prompts/sast-enrichment-injection.txt new file mode 100644 index 00000000..511ac4f0 --- /dev/null +++ b/apps/worker/prompts/sast-enrichment-injection.txt @@ -0,0 +1,16 @@ +@include(shared/exploitation/_sast-enrichment-procedure.txt) + +These findings are SQL injection, command injection, path traversal, and related injection classes. Each finding must be transformed into a vulnerability object matching the schema. + +CRITICAL RULES: +- witness_payload MUST be tailored to the actual sink code. If the sink is `db.query("SELECT * FROM users WHERE name LIKE '%" + input + "%'")`, use `%' OR '%'='` not a generic `' OR 1=1--`. +- slot_type MUST reflect the actual SQL/command/file context from the code snippet. +- If dataflow path is provided, use it to build an accurate `path` field. +- If sanitization functions appear in the path, list them in `sanitization_observed` and explain in `mismatch_reason` why they're insufficient. +- Set externally_exploitable=true only if the source is user-controlled input (HTTP params, headers, request body, cookies). +- _sastId MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it. +- For XML injection (CWE-91): slot_type is XML-element or XML-attribute depending on where user input lands in the XML structure. +- For prompt injection (CWE-1427): slot_type is PROMPT-instruction. witness_payload should demonstrate instruction override, not generic text. +- For prototype pollution (CWE-1321): slot_type is PROTO-property. witness_payload should use __proto__ or constructor.prototype paths specific to the sink. + +SAST FINDINGS: diff --git a/apps/worker/prompts/sast-enrichment-miscellaneous.txt b/apps/worker/prompts/sast-enrichment-miscellaneous.txt new file mode 100644 index 00000000..7b92b157 --- /dev/null +++ b/apps/worker/prompts/sast-enrichment-miscellaneous.txt @@ -0,0 +1,14 @@ +@include(shared/exploitation/_sast-enrichment-procedure.txt) + +These findings are weaknesses that fall outside the injection, XSS, authentication, authorization and SSRF classes. They share no family: session lifetime, error-message disclosure, sensitive logging, cleartext storage, request forgery, redirection, framing, algorithmic complexity, race conditions. + +CRITICAL RULES: +- vulnerability_type is the weakness's own name, taken from the CWE on the finding (e.g. 'Insecure Randomness', 'Use of Hard-coded Cryptographic Key'). There is no fixed list to pick from, and it must not be forced into another class's vocabulary. +- proof_criterion is the field the exploitation agent works from: state the concrete observation that would settle whether this specific weakness is real. These findings carry no per-class proof ladder, so an unusable criterion leaves the agent nothing to aim at. +- observable_signal must be something visible from outside the application, not a restatement of the source code. +- exploitation_hypothesis must describe what an attacker ACHIEVES, not just confirm the weakness exists. +- suggested_exploit_technique must be an actionable attack the exploitation agent can execute against a live application. +- cwe carries the id from the finding, e.g. CWE-330. +- _sastId MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it. + +SAST FINDINGS: diff --git a/apps/worker/prompts/sast-enrichment-ssrf.txt b/apps/worker/prompts/sast-enrichment-ssrf.txt new file mode 100644 index 00000000..77b9f193 --- /dev/null +++ b/apps/worker/prompts/sast-enrichment-ssrf.txt @@ -0,0 +1,11 @@ +@include(shared/exploitation/_sast-enrichment-procedure.txt) + +These findings are Server-Side Request Forgery vulnerabilities. + +CRITICAL RULES: +- vulnerability_type must match the sink pattern: HTTP client → URL_Manipulation, redirect function → Redirect_Abuse, webhook registration → Webhook_Injection. +- exploitation_hypothesis should reference likely internal targets (cloud metadata, internal APIs, admin panels) based on code context. +- suggested_exploit_technique must be actionable — the exploitation agent will actually attempt this against the live app. +- _sastId MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it. + +SAST FINDINGS: diff --git a/apps/worker/prompts/sast-enrichment-xss.txt b/apps/worker/prompts/sast-enrichment-xss.txt new file mode 100644 index 00000000..99dc5440 --- /dev/null +++ b/apps/worker/prompts/sast-enrichment-xss.txt @@ -0,0 +1,11 @@ +@include(shared/exploitation/_sast-enrichment-procedure.txt) + +These findings are Cross-Site Scripting vulnerabilities. + +CRITICAL RULES: +- Determine vulnerability_type from the source: HTTP request param → Reflected, database read → Stored, client-side only → DOM-based. +- render_context MUST be inferred from the actual sink code. `innerHTML` → HTML_BODY, `setAttribute('href', ...)` → HTML_ATTRIBUTE, template literal in