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