mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-17 07:25:35 +02:00
feat(worker): add agentic static analysis
Add the ten-stage Agentic SAST pipeline, confined repository tools, model runtime, prompt templates, and SARIF export. Make retries, repair sessions, reduced coverage, usage accounting, and model-output drift durable across Temporal replay and resume. Keep retry diagnostics in their actionable closed vocabulary. Package the Mantis-derived license material with the prompts that require it.
This commit is contained in:
@@ -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
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)**.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 <role>"`, `"customer with"`,
|
||||
`"tenant <role>"`, `"Fitbit user"` on a public product ->
|
||||
`"EXTERNAL"` (with `privileges_required: "LOW"`).
|
||||
- Phrases matching `"local user"`, `"local shell"`,
|
||||
`"local access"` -> `"LOCAL"`.
|
||||
- Phrases matching `"peer <role> 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: <rule_name>)"` (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}}
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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 `:<digits>` to get the line number; `://` means a URL, not a file;
|
||||
any entry that is not `<path>:<int>` 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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
`<path>:<line>`, 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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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<ModelSelection>;
|
||||
classify(error: unknown, contextWindow?: number): ProviderFailure;
|
||||
}
|
||||
|
||||
export type ModelSelectionResolver = () => Promise<ModelSelection>;
|
||||
|
||||
class ShannonModelHost implements ModelHost {
|
||||
private selection: Promise<ModelSelection> | 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<ModelSelection> {
|
||||
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();
|
||||
@@ -232,10 +232,11 @@ export async function createModelRuntime(providerId: string, apiKey: string | un
|
||||
}
|
||||
|
||||
export interface ModelSelection {
|
||||
model: Model<Api>;
|
||||
modelRuntime: ModelRuntime;
|
||||
modelId: string;
|
||||
providerId: string;
|
||||
readonly model: Model<Api>;
|
||||
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<ModelSelection> {
|
||||
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<ModelSelection> {
|
||||
modelRuntime,
|
||||
modelId,
|
||||
providerId,
|
||||
credentialSource: mountedPiAuth ? 'pi-auth' : credentials.apiKey ? 'api-key' : 'ambient',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<unknown>): 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<string>();
|
||||
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<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(cancellationError(signal));
|
||||
return new Promise<T>((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<T>(request: CapellaAgentRequest<T>): Promise<CapellaAgentResponse<T>> {
|
||||
assertRequest(request as CapellaAgentRequest<unknown>);
|
||||
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<T>(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<T>(
|
||||
request: CapellaAgentRequest<T>,
|
||||
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();
|
||||
@@ -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<T> {
|
||||
readonly output: T;
|
||||
readonly usage: CapellaUsage;
|
||||
}
|
||||
|
||||
/** Standalone executor boundary consumed by the Capella stage implementation. */
|
||||
export interface CapellaAgentExecutor {
|
||||
run<T>(request: CapellaAgentRequest<T>): Promise<CapellaAgentResponse<T>>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>) => Promise<unknown>;
|
||||
|
||||
async function captureSingleValidSubmission(
|
||||
toolCalls: readonly ToolCall[],
|
||||
submitTool: CapturedSubmitTool,
|
||||
): Promise<Array<{ name: string; arguments: unknown }>> {
|
||||
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<StructuredGenerationResult> {
|
||||
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<void> {
|
||||
return {
|
||||
generate(request: StructuredGenerationRequest): Promise<StructuredGenerationResult> {
|
||||
return generate(host, request);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = Object.create(null) as Record<string, unknown>;
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
const child = (value as Record<string, unknown>)[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, unknown>): string {
|
||||
return sha256Bytes(stableJson(parts));
|
||||
}
|
||||
|
||||
/** Resolve the immutable repository commit used by all stage fingerprints. */
|
||||
export async function repositoryIdentity(repoPath: string): Promise<string> {
|
||||
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<string> {
|
||||
assertOwnedPath(artifactRoot, finalPath);
|
||||
await mkdir(dirname(finalPath), { recursive: true });
|
||||
const temporaryPath = resolve(dirname(finalPath), `.${basename(finalPath)}.${process.pid}.${randomUUID()}.tmp`);
|
||||
let handle: Awaited<ReturnType<typeof open>> | 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<string, unknown>;
|
||||
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<T>(
|
||||
value: unknown,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
validate: StageArtifactValidator<T>,
|
||||
): value is CapellaArtifactEnvelope<T> {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
record.schemaVersion === 1 &&
|
||||
record.stage === stage &&
|
||||
record.fingerprint === fingerprint &&
|
||||
isUsage(record.usage) &&
|
||||
validate(record.value)
|
||||
);
|
||||
}
|
||||
|
||||
export interface LoadedArtifact<T> {
|
||||
readonly ref: CapellaArtifactRef;
|
||||
readonly value: T;
|
||||
readonly usage: CapellaUsage;
|
||||
}
|
||||
|
||||
/** Return only a schema-valid, fingerprint-matching completed artifact. */
|
||||
export async function loadCompletedArtifact<T>(
|
||||
artifactRoot: string,
|
||||
finalPath: string,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
validate: StageArtifactValidator<T>,
|
||||
): Promise<LoadedArtifact<T> | 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<T>(
|
||||
artifactRoot: string,
|
||||
ref: CapellaArtifactRef,
|
||||
stage: CapellaStage,
|
||||
validate: StageArtifactValidator<T>,
|
||||
): Promise<LoadedArtifact<T>> {
|
||||
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<T>(
|
||||
artifactRoot: string,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
usage: CapellaUsage,
|
||||
value: T,
|
||||
): Promise<CapellaArtifactRef> {
|
||||
const finalPath = stageArtifactPath(artifactRoot, stage);
|
||||
const envelope: CapellaArtifactEnvelope<T> = { 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<T>(
|
||||
artifactRoot: string,
|
||||
finalPath: string,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
usage: CapellaUsage,
|
||||
value: T,
|
||||
): Promise<CapellaArtifactRef> {
|
||||
const envelope: CapellaArtifactEnvelope<T> = { 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<Record<CapellaStage, CapellaUsage>>): 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<CapellaStage, AgenticSastReduction>();
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<CapellaRunRecord | undefined> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Record<CapellaAgentErrorName, boolean>>);
|
||||
|
||||
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<CapellaAgentErrorName, keyof typeof CAPELLA_ERROR_TYPE_NON_RETRYABLE>;
|
||||
const _everyCapellaAgentErrorHasTypeGate: UnclassifiedCapellaAgentError extends never ? true : never = true;
|
||||
void _everyCapellaAgentErrorHasTypeGate;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<TriageRuleKey, TriageRuleEvaluation>;
|
||||
|
||||
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<CalibrationRuleKey, CalibrationRuleEvaluation>;
|
||||
|
||||
// === 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/<id>.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;
|
||||
}
|
||||
@@ -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}/`);
|
||||
});
|
||||
}
|
||||
@@ -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.',
|
||||
'',
|
||||
'<capella_knowledge_base_json>',
|
||||
stableJson(knowledgeBase).trimEnd(),
|
||||
'</capella_knowledge_base_json>',
|
||||
].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.',
|
||||
'',
|
||||
'<capella_findings_json>',
|
||||
stableJson(ordered).trimEnd(),
|
||||
'</capella_findings_json>',
|
||||
].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.',
|
||||
'',
|
||||
'<capella_referenced_kb_json>',
|
||||
stableJson(kbEntries).trimEnd(),
|
||||
'</capella_referenced_kb_json>',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -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<Record<string, unknown>>,
|
||||
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<string, Handlebars.TemplateDelegate>();
|
||||
|
||||
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<Record<string, unknown>> = {},
|
||||
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);
|
||||
}
|
||||
@@ -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<string>,
|
||||
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`;
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
@@ -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<ReturnType<typeof parseCodePath>> => {
|
||||
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<string, CapellaSarifRule>();
|
||||
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<SarifRef | undefined> {
|
||||
try {
|
||||
const run = JSON.parse(await readFile(resolve(artifactRoot, 'run.json'), 'utf8')) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<CapellaExportResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, string[]>;
|
||||
}
|
||||
|
||||
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/<name>.md)' },
|
||||
vulnerabilities: {
|
||||
type: 'array',
|
||||
items: KB_ENTITY,
|
||||
description: 'One entry per bug class (kb/vulnerabilities/<name>.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<string, unknown>;
|
||||
|
||||
// === 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<string, unknown>;
|
||||
|
||||
// === 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<string, unknown>;
|
||||
|
||||
// === 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<string, unknown>;
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string, Array<{ entity: KbEntity; contentHash: string }>>();
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
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<CompletedStage<ArchitectureValue>> {
|
||||
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<unknown>({
|
||||
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<CompletedStage<ThreatModelValue>> {
|
||||
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<ThreatModelResult>({
|
||||
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<CompletedStage<PlanValue>> {
|
||||
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<capella_threat_model>\n${threatModel.value.threatModel}\n</capella_threat_model>`;
|
||||
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<unknown>({
|
||||
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);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<CompletedStage<ExportValue>> {
|
||||
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;
|
||||
}
|
||||
@@ -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<T> {
|
||||
readonly value: T;
|
||||
readonly reused: boolean;
|
||||
}
|
||||
|
||||
interface PoolSuccess<T> {
|
||||
readonly status: 'succeeded';
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
interface PoolFailure {
|
||||
readonly status: 'failed';
|
||||
readonly error: unknown;
|
||||
}
|
||||
|
||||
type PoolOutcome<T> = PoolSuccess<T> | PoolFailure;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, string>,
|
||||
planLineage: Record<string, string>,
|
||||
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<T, R>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
run: (item: T, index: number) => Promise<R>,
|
||||
): Promise<Array<PoolOutcome<R>>> {
|
||||
const results: Array<PoolOutcome<R>> = [];
|
||||
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<T>(outcomes: readonly PoolOutcome<T>[]): 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<string>();
|
||||
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<string>();
|
||||
const classifiedFiles = new Set<string>();
|
||||
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<string, CapellaFinding>();
|
||||
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<CompletedStage<ResearchValue>> {
|
||||
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<TriageResult>({
|
||||
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<TriageResult>({
|
||||
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<void>({
|
||||
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<PoolItemResult<unknown> & { 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);
|
||||
}
|
||||
@@ -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<StageIdentity> {
|
||||
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, unknown>,
|
||||
): 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<T>(
|
||||
input: CapellaStageInput,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
validate: StageArtifactValidator<T>,
|
||||
identity: StageIdentity,
|
||||
startedAt: number,
|
||||
reuseGuard?: (value: T) => Promise<boolean>,
|
||||
): Promise<CompletedStage<T> | 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<T>(
|
||||
input: CapellaStageInput,
|
||||
stage: CapellaStage,
|
||||
fingerprint: string,
|
||||
usage: CapellaUsage,
|
||||
value: T,
|
||||
identity: StageIdentity,
|
||||
startedAt: number,
|
||||
warnings: readonly string[] = [],
|
||||
sarif?: SarifRef,
|
||||
additionalReductions: readonly AgenticSastReduction[] = [],
|
||||
): Promise<CompletedStage<T>> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<T>(
|
||||
artifactRoot: string,
|
||||
findings: readonly CapellaFinding[],
|
||||
run: (findingsDir: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<CapellaAgentResponse<void>>,
|
||||
acceptedMutationCount: () => number,
|
||||
): Promise<CollectorSessionOutcome> {
|
||||
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<ActiveFindingsResult> {
|
||||
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<string, string> {
|
||||
return { path: ref.path, sha256: ref.sha256, fingerprint: ref.fingerprint };
|
||||
}
|
||||
@@ -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<Record<string, unknown>>,
|
||||
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<Record<string, unknown>>,
|
||||
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<ResearchValue> {
|
||||
return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'research', isResearchValue)).value;
|
||||
}
|
||||
|
||||
async function loadDedupeFindings(input: FindingStageInput): Promise<DedupeValue> {
|
||||
return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'dedupe', isDedupeValue)).value;
|
||||
}
|
||||
|
||||
async function loadReviewFindings(input: FindingStageInput): Promise<ReviewValue> {
|
||||
return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'review', isReviewValue)).value;
|
||||
}
|
||||
|
||||
async function loadCriticFindings(input: FindingStageInput): Promise<CriticValue> {
|
||||
return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'critic', isCriticValue)).value;
|
||||
}
|
||||
|
||||
async function loadConfirmFindings(input: FindingStageInput): Promise<ConfirmValue> {
|
||||
return (await loadArtifactRef(input.artifactRoot, input.findingsArtifact, 'confirm', isConfirmValue)).value;
|
||||
}
|
||||
|
||||
async function knowledgeContext(input: KnowledgeFindingStageInput): Promise<string> {
|
||||
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<capella_threat_model>\n${threatModel.value.threatModel}\n</capella_threat_model>`;
|
||||
}
|
||||
|
||||
export async function runDedupeStage(
|
||||
input: FindingStageInput,
|
||||
runtime: CapellaStageRuntime,
|
||||
): Promise<CompletedStage<DedupeValue>> {
|
||||
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<void>({
|
||||
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<CompletedStage<ReviewValue>> {
|
||||
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<void>({
|
||||
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<void>({
|
||||
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<CompletedStage<CriticValue>> {
|
||||
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<void>({
|
||||
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<void>({
|
||||
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<CompletedStage<ConfirmValue>> {
|
||||
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<void>({
|
||||
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<void>({
|
||||
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<CompletedStage<CalibrateValue>> {
|
||||
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<void>({
|
||||
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<void>({
|
||||
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);
|
||||
}
|
||||
@@ -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<T> = (runtime: CapellaStageRuntime) => Promise<CompletedStage<T>>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<void> {
|
||||
const directory = usageRecordDirectory(artifactRoot, identity);
|
||||
const path = usageRecordPath(artifactRoot, identity, record.state);
|
||||
const bytes = stableJson(record);
|
||||
await mkdir(directory, { recursive: true });
|
||||
let handle: Awaited<ReturnType<typeof open>> | 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<void> {
|
||||
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<ReturnType<typeof open>> | 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<string, unknown>;
|
||||
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<string[]> {
|
||||
let entries: Dirent<string>[];
|
||||
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<StageUsageSummary> {
|
||||
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<string, unknown>;
|
||||
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<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly delegate: CapellaAgentExecutor,
|
||||
private readonly artifactRoot: string,
|
||||
private readonly baseIdentity: Omit<UsageRecordIdentity, 'workloadId' | 'sessionNumber'>,
|
||||
) {}
|
||||
|
||||
async run<T>(request: CapellaAgentRequest<T>): Promise<CapellaAgentResponse<T>> {
|
||||
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<T> | 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<unknown>();
|
||||
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<string> {
|
||||
return buildRunInputFingerprint(input, await repositoryIdentity(input.repoPath));
|
||||
}
|
||||
|
||||
async function runStageActivity<T, V>(
|
||||
stage: CapellaStage,
|
||||
input: CapellaStageInput,
|
||||
run: StageRunner<T>,
|
||||
compact: (value: T) => V,
|
||||
): Promise<CapellaActivityResult<V>> {
|
||||
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<CapellaArchitectureActivityResult> {
|
||||
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<CapellaThreatModelActivityResult> {
|
||||
return runStageActivity(
|
||||
'threat-model',
|
||||
input,
|
||||
(runtime) => runThreatModelStage(input, runtime),
|
||||
(value) => ({
|
||||
intent: value.intent,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function capellaPlan(input: CapellaPlanActivityInput): Promise<CapellaPlanActivityResult> {
|
||||
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<CapellaResearchActivityResult> {
|
||||
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<CapellaDedupeActivityResult> {
|
||||
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<CapellaReviewActivityResult> {
|
||||
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<CapellaCriticActivityResult> {
|
||||
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<CapellaConfirmActivityResult> {
|
||||
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<CapellaCalibrateActivityResult> {
|
||||
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<CapellaExportActivityResult> {
|
||||
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 }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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<T> {
|
||||
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<CapellaArchitectureActivityValue>;
|
||||
export type CapellaThreatModelActivityResult = CapellaActivityResult<CapellaThreatModelActivityValue>;
|
||||
export type CapellaPlanActivityResult = CapellaActivityResult<CapellaPlanActivityValue>;
|
||||
export type CapellaResearchActivityResult = CapellaActivityResult<CapellaResearchActivityValue>;
|
||||
export type CapellaDedupeActivityResult = CapellaActivityResult<CapellaDedupeActivityValue>;
|
||||
export type CapellaReviewActivityResult = CapellaActivityResult<CapellaReviewActivityValue>;
|
||||
export type CapellaCriticActivityResult = CapellaActivityResult<CapellaCriticActivityValue>;
|
||||
export type CapellaConfirmActivityResult = CapellaActivityResult<CapellaConfirmActivityValue>;
|
||||
export type CapellaCalibrateActivityResult = CapellaActivityResult<CapellaCalibrateActivityValue>;
|
||||
export type CapellaExportActivityResult = CapellaActivityResult<CapellaExportActivityValue>;
|
||||
|
||||
/**
|
||||
* 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<CapellaArchitectureActivityResult>;
|
||||
readonly capellaThreatModel: (input: CapellaThreatModelActivityInput) => Promise<CapellaThreatModelActivityResult>;
|
||||
readonly capellaPlan: (input: CapellaPlanActivityInput) => Promise<CapellaPlanActivityResult>;
|
||||
readonly capellaResearch: (input: CapellaResearchActivityInput) => Promise<CapellaResearchActivityResult>;
|
||||
readonly capellaDedupe: (input: CapellaFindingActivityInput) => Promise<CapellaDedupeActivityResult>;
|
||||
readonly capellaReview: (input: CapellaFindingActivityInput) => Promise<CapellaReviewActivityResult>;
|
||||
readonly capellaCritic: (input: CapellaKnowledgeFindingActivityInput) => Promise<CapellaCriticActivityResult>;
|
||||
readonly capellaConfirm: (input: CapellaFindingActivityInput) => Promise<CapellaConfirmActivityResult>;
|
||||
readonly capellaCalibrate: (input: CapellaKnowledgeFindingActivityInput) => Promise<CapellaCalibrateActivityResult>;
|
||||
readonly capellaExport: (input: CapellaExportActivityInput) => Promise<CapellaExportActivityResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CapellaActivityPolicy> {
|
||||
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<Record<keyof CapellaActivityRegistry, Readonly<CapellaActivityPolicy>>>);
|
||||
|
||||
/** Bounds the whole child pipeline, including every stage's retries and backoff. */
|
||||
export const CAPELLA_CHILD_WORKFLOW_TIMEOUT_MS = 15 * HOUR_MS;
|
||||
@@ -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<CapellaActivityRegistry> = Object.freeze(registry);
|
||||
|
||||
type UnionToIntersection<T> = (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<const T extends readonly object[]>(
|
||||
...registries: T
|
||||
): Readonly<UnionToIntersection<T[number]>> {
|
||||
const merged: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
||||
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<UnionToIntersection<T[number]>>;
|
||||
}
|
||||
@@ -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<ChildWorkflowOptions, 'workflowExecutionTimeout' | 'cancellationType'>);
|
||||
|
||||
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<Pick<CapellaActivityRegistry, 'capellaArchitecture'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaArchitecture),
|
||||
);
|
||||
const threatModelActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaThreatModel'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaThreatModel),
|
||||
);
|
||||
const planActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaPlan'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaPlan),
|
||||
);
|
||||
const researchActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaResearch'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaResearch),
|
||||
);
|
||||
const dedupeActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaDedupe'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaDedupe),
|
||||
);
|
||||
const reviewActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaReview'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaReview),
|
||||
);
|
||||
const criticActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaCritic'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaCritic),
|
||||
);
|
||||
const confirmActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaConfirm'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaConfirm),
|
||||
);
|
||||
const calibrateActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaCalibrate'>>(
|
||||
activityOptions(CAPELLA_ACTIVITY_POLICIES.capellaCalibrate),
|
||||
);
|
||||
const exportActivities = proxyActivities<Pick<CapellaActivityRegistry, 'capellaExport'>>(
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<unknown>();
|
||||
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<unknown>();
|
||||
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<T>(accumulator: WorkflowAccumulator, stage: CapellaStage, result: CapellaActivityResult<T>): 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<CapellaRunResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Stats> {
|
||||
try {
|
||||
return await lstat(target);
|
||||
} catch {
|
||||
throw confinementError(code);
|
||||
}
|
||||
}
|
||||
|
||||
async function realpathForConfinement(target: string, code: ConfinementErrorCode): Promise<string> {
|
||||
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<RepositoryConfinement> {
|
||||
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<string> {
|
||||
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<ConfinedFile> {
|
||||
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<ConfinedFile> {
|
||||
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<readonly ConfinedEntry[]> {
|
||||
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');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<object>();
|
||||
|
||||
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<T extends ToolDefinition>(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<readonly ConfinedEntry[]> {
|
||||
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<readonly CapellaTool[]> {
|
||||
const confinement = await RepositoryConfinement.create(options);
|
||||
return Object.freeze([createReadTool(confinement), createFindTool(confinement), createGrepTool(confinement)]);
|
||||
}
|
||||
@@ -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<T> {
|
||||
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<T> {
|
||||
readonly schemaVersion: 1;
|
||||
readonly stage: CapellaStage;
|
||||
readonly fingerprint: string;
|
||||
readonly usage: CapellaUsage;
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
export type StageArtifactValidator<T> = (value: unknown) => value is T;
|
||||
|
||||
export interface AtomicPublishOptions {
|
||||
readonly beforeRename?: (temporaryPath: string, finalPath: string) => Promise<void> | 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<Record<CapellaStage, CapellaUsage>>;
|
||||
// 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.`;
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<T extends string>(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/<id>.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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>, 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
return (
|
||||
record.reduction === undefined ||
|
||||
(isAgenticSastReduction(record.reduction) && record.reduction.stage === stage && record.reduction.reason === reason)
|
||||
);
|
||||
}
|
||||
|
||||
function hasPrivateVerdictDiagnostics(value: Record<string, unknown>): boolean {
|
||||
return hasInteger(value, 'rejectedUnexpectedCount') && hasInteger(value, 'rejectedDuplicateCount');
|
||||
}
|
||||
|
||||
function hasMatchingVerdictReduction(
|
||||
value: Record<string, unknown>,
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).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<string, unknown>, required: readonly string[]): boolean {
|
||||
return Object.keys(value).length === required.length && required.every((key) => key in value);
|
||||
}
|
||||
|
||||
function hasBoundedCounts(value: Record<string, unknown>, 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;
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<string>, 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<string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<CapellaStage, 'export'>;
|
||||
|
||||
/** 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[];
|
||||
};
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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<TModelContext> {
|
||||
generate(request: StructuredGenerationRequest, modelContext: TModelContext): Promise<StructuredGenerationResult>;
|
||||
}
|
||||
@@ -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<string> = new Set([
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'ETIMEDOUT',
|
||||
'EPIPE',
|
||||
'EAI_AGAIN',
|
||||
'ENOTFOUND',
|
||||
]);
|
||||
|
||||
const PROVIDER_FAILURE_MESSAGES: Readonly<Record<ProviderFailureCategory, string>> = {
|
||||
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<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Generated
+40
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user