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:
ajmallesh
2026-08-26 19:26:36 -07:00
parent ed5659e2e2
commit 980607c602
68 changed files with 11722 additions and 65 deletions
+2
View File
@@ -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
+4
View File
@@ -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 && \
+201
View File
@@ -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.
+36
View File
@@ -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.
+1 -1
View File
@@ -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"
+5 -4
View File
@@ -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`.
+54
View File
@@ -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();
+7 -4
View File
@@ -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>>;
}
+4 -4
View File
@@ -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);
},
};
}
+17 -26
View File
@@ -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;
+69
View File
@@ -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;
}
+63
View File
@@ -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);
}
+94
View File
@@ -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,
};
}
+159
View File
@@ -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)]);
}
+283
View File
@@ -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;
});
}
+253
View File
@@ -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 };
}
+183
View File
@@ -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>;
}
+254 -21
View File
@@ -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 };
}
+25
View File
@@ -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;
}
+40 -5
View File
@@ -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