mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-20 00:42:24 +02:00
merge: integrate Shannon 3.0 with public v2.6.0
- preserve the versioned and non-TTY banners from public main - keep workspace launch classification ahead of shared infrastructure setup - carry the eleven-commit Agentic SAST feature history unchanged - normalize Capella prompt endings to the accepted candidate tree
This commit is contained in:
@@ -125,16 +125,18 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"vuln_classes": {
|
||||
"type": "array",
|
||||
"description": "Vulnerability classes to test. When omitted, all five classes run. When set, only listed classes run; their vuln+exploit agents and report sections are included.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["injection", "xss", "auth", "authz", "ssrf"]
|
||||
"agentic_sast": {
|
||||
"type": "object",
|
||||
"description": "Opt in to agentic static analysis, which reads the repository for vulnerabilities before the pentest and feeds what it finds into the exploitation phase. Off by default. It does not change which vulnerability classes run. If agentic static analysis fails, the pentest continues without its findings and the scan finishes as \"partial\".",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "string",
|
||||
"enum": ["true", "false"],
|
||||
"description": "Set to \"true\" to run agentic static analysis. Defaults to \"false\"."
|
||||
}
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 5,
|
||||
"uniqueItems": true
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"exploit": {
|
||||
"type": "string",
|
||||
@@ -193,7 +195,7 @@
|
||||
{ "required": ["rules"] },
|
||||
{ "required": ["authentication", "rules"] },
|
||||
{ "required": ["description"] },
|
||||
{ "required": ["vuln_classes"] },
|
||||
{ "required": ["agentic_sast"] },
|
||||
{ "required": ["exploit"] },
|
||||
{ "required": ["report"] },
|
||||
{ "required": ["rules_of_engagement"] }
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
# Description of the target environment (optional, max 500 chars)
|
||||
description: "Next.js e-commerce app on PostgreSQL. Local dev environment — .env files contain local-only credentials, not deployed to production."
|
||||
|
||||
# Limit which vulnerability classes run end-to-end (optional, default: all five)
|
||||
# vuln_classes: [injection, xss, auth, authz, ssrf]
|
||||
# Every scan runs all five vulnerability classes: injection, xss, auth, authz, and ssrf.
|
||||
# There is no setting to narrow that.
|
||||
|
||||
# Agentic static analysis (optional, default: "false").
|
||||
# Reads the repository for vulnerabilities before the pentest and feeds them into exploitation.
|
||||
# It costs extra model time, and if it fails the scan finishes as "partial" without its findings.
|
||||
# agentic_sast:
|
||||
# enabled: "true"
|
||||
|
||||
# Skip the exploitation phase (optional, default: "true")
|
||||
# exploit: "false"
|
||||
|
||||
@@ -10,7 +10,27 @@
|
||||
"./types/agents": "./dist/types/agents.js",
|
||||
"./pipeline": "./dist/temporal/pipeline.js",
|
||||
"./activities": "./dist/temporal/activities.js",
|
||||
"./temporal/reconcile-activity-types": "./dist/temporal/reconcile-activity-types.js",
|
||||
"./services": "./dist/services/index.js",
|
||||
"./services/queue-validation": "./dist/services/queue-validation.js",
|
||||
"./services/renumber-core": "./dist/services/renumber-core.js",
|
||||
"./services/compaction-core": "./dist/services/compaction-core.js",
|
||||
"./services/finding-order": "./dist/services/finding-order.js",
|
||||
"./ai/structured-generation": "./dist/ai/structured-generation.js",
|
||||
"./ai/pi/source-jail": "./dist/ai/pi/source-jail.js",
|
||||
"./ai/reconciliation/contracts": "./dist/ai/reconciliation/contracts.js",
|
||||
"./ai/reconciliation/stage-contracts": "./dist/ai/reconciliation/stage-contracts.js",
|
||||
"./ai/reconciliation/artifact-store": "./dist/ai/reconciliation/artifact-store.js",
|
||||
"./ai/reconciliation/schema-version": "./dist/ai/reconciliation/schema-version.js",
|
||||
"./ai/reconciliation/manifest": "./dist/ai/reconciliation/manifest.js",
|
||||
"./ai/reconciliation/prepare": "./dist/ai/reconciliation/prepare.js",
|
||||
"./ai/reconciliation/enrich": "./dist/ai/reconciliation/enrich.js",
|
||||
"./ai/reconciliation/form": "./dist/ai/reconciliation/form.js",
|
||||
"./ai/reconciliation/materialize": "./dist/ai/reconciliation/materialize.js",
|
||||
"./ai/reconciliation/observation-view": "./dist/ai/reconciliation/observation-view.js",
|
||||
"./ai/reconciliation/labels": "./dist/ai/reconciliation/labels.js",
|
||||
"./ai/reconciliation/submit-validation": "./dist/ai/reconciliation/submit-validation.js",
|
||||
"./ai/reconciliation/refs": "./dist/ai/reconciliation/refs.js",
|
||||
"./config": "./dist/config-parser.js"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -23,13 +43,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"
|
||||
|
||||
@@ -8,7 +8,7 @@ Success criterion: A comprehensive evidence report detailing the outcome for **e
|
||||
</objective>
|
||||
|
||||
<exploitation_scope>
|
||||
@include(shared/_exploit-scope.txt)
|
||||
@include(shared/exploitation/_exploit-scope.txt)
|
||||
</exploitation_scope>
|
||||
|
||||
<critical>
|
||||
@@ -53,21 +53,23 @@ Remember: An unproven vulnerability is worse than no finding at all - it wastes
|
||||
|
||||
**Level 1 - Authentication Weakness Identified**
|
||||
- Evidence: Theoretical bypass found but not yet exploited
|
||||
- Classification: POTENTIAL (Low Confidence)
|
||||
- Classification: `status: "blocked"`, `confidence: "low"`
|
||||
|
||||
**Level 2 - Partial Bypass Achieved**
|
||||
- Evidence: Access gained to non-critical protected resources or UI elements
|
||||
- Classification: POTENTIAL (Medium Confidence)
|
||||
- Classification: `status: "blocked"`, `confidence: "medium"`
|
||||
|
||||
**Level 3 - Authentication Bypass Confirmed**
|
||||
- Evidence: Successfully accessed protected functionality as unauthorized user
|
||||
- Classification: EXPLOITED
|
||||
- Classification: `status: "exploited"`
|
||||
|
||||
**Level 4 - Account Takeover Demonstrated**
|
||||
- Evidence: Complete user impersonation with access to sensitive data/actions
|
||||
- Classification: EXPLOITED (CRITICAL)
|
||||
- Classification: `status: "exploited"`
|
||||
|
||||
You MUST reach at least Level 3 with evidence to mark as "EXPLOITED"
|
||||
Level 3 is the threshold for claiming demonstrated impact and rating the finding `low` or above. Reaching Level 1 or 2 and being stopped by an external operational constraint is `status: "blocked"`.
|
||||
|
||||
These levels measure how conclusively you proved the finding is real. They do not set its severity — a finding proven at Level 4 may still be Low. Severity is decided separately, in <severity_reasoning>.
|
||||
|
||||
### Classification Decision Framework
|
||||
|
||||
@@ -231,26 +233,26 @@ Only after exhausting bypass attempts can you proceed to classification analysis
|
||||
## **Validation Criteria & Vulnerability Classification**
|
||||
You must adhere to these rules when making a judgment call and categorizing findings:
|
||||
|
||||
**EXPLOITED (Report in main section):**
|
||||
**`status: "exploited"`:**
|
||||
- Successfully achieved account takeover or authentication bypass with concrete evidence (impersonated user, accessed protected resources)
|
||||
- OR vulnerability confirmed with active exploitation evidence (e.g., "Performed 200 brute force attempts without rate limiting")
|
||||
- Definitive proof of access to a protected resource as another authenticated user OR concrete demonstration of security control bypass
|
||||
- Must have completed weaponization and demonstrated impact through impersonation OR provided evidence of exploitable vulnerability
|
||||
|
||||
POTENTIAL (Report in separate subsection):
|
||||
- This classification is exclusively for vulnerabilities where a definitive conclusion is impossible due to external factors beyond your control.
|
||||
**`status: "blocked"`:**
|
||||
- This status is exclusively for vulnerabilities where a definitive conclusion is impossible due to external factors beyond your control.
|
||||
- **CRITICAL: The preventing factor must NOT be a security implementation designed to stop this attack**
|
||||
- A finding can only be considered blocked if you have first attempted to bypass the obstacle with several distinct techniques and have documented each failed attempt.
|
||||
- Valid external factors include:
|
||||
- Server instability (e.g., crashes).
|
||||
- Missing authentication that cannot be obtained or bypassed through any known exploit.
|
||||
- This classification also applies when:
|
||||
- This status also applies when:
|
||||
- Code analysis suggests a vulnerability, but live testing is inconclusive due to unmovable constraints.
|
||||
- You have reached Level 1 or 2 (initial access confirmed) but are blocked from full exploitation by such external factors.
|
||||
|
||||
**WARNING: When security measures successfully prevent exploitation after exhaustive testing, it is ok to classify it as a FALSE POSITIVE, not POTENTIAL. A FALSE POSITIVE that's honestly classified is more valuable than a misclassified POTENTIAL.**
|
||||
**WARNING: When security measures successfully prevent exploitation after exhaustive testing, it is ok to classify it as a FALSE POSITIVE, not `blocked`. A FALSE POSITIVE that's honestly classified is more valuable than a misclassified `blocked` finding.**
|
||||
|
||||
**FALSE POSITIVE (Do NOT report):**
|
||||
**FALSE POSITIVE (never submitted through the tool):**
|
||||
- **The blocking mechanism IS a security implementation designed to prevent this attack AND you have attempted to bypass it multiple ways**
|
||||
- Definitively proven not exploitable through exhaustive testing
|
||||
- Documented attack plan showing all weaponization attempts failed
|
||||
@@ -260,7 +262,7 @@ POTENTIAL (Report in separate subsection):
|
||||
|
||||
**Important:** Rate limiting and account lockouts are obstacles to work around, not grounds for immediate false positive classification. Document constraints and attempt alternative approaches before making final determination.
|
||||
|
||||
**Critical Decision Test**: After exhaustive bypass attempts, ask "Is this prevention a security feature designed to stop authentication attacks, or an external operational constraint?" Security features that withstand bypass attempts = FALSE POSITIVE. External constraints = POTENTIAL.
|
||||
**Critical Decision Test**: After exhaustive bypass attempts, ask "Is this prevention a security feature designed to stop authentication attacks, or an external operational constraint?" Security features that withstand bypass attempts = FALSE POSITIVE. External constraints = `blocked`.
|
||||
|
||||
### CRITICAL: REPRODUCIBILITY REQUIREMENTS
|
||||
|
||||
@@ -304,12 +306,18 @@ Prioritize these based on the intelligence from your queue and vulnerability ana
|
||||
</attack_patterns>
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
@include(shared/exploitation/_severity-reasoning.txt)
|
||||
|
||||
@include(shared/exploitation/_reporting-standards.txt)
|
||||
|
||||
@include(shared/exploitation/_credentials-in-findings.txt)
|
||||
|
||||
<deliverable_tools>
|
||||
You emit your exploitation evidence through a single tool — `add_exploit`. The host renderer assembles `.shannon/deliverables/auth_exploitation_evidence.md` from your tool calls after the run. You do NOT write the Markdown file directly.
|
||||
|
||||
**When to emit.** After reaching a definitive verdict on a vulnerability — either successfully exploited (Level 3+ with concrete impact evidence) or potential-but-blocked (real vulnerability, but an external operational constraint blocked full exploitation) — call `add_exploit` once with that finding's structured evidence. Call once per queue vulnerability; do not batch. Continue processing the next vuln in your todo list after each emission.
|
||||
|
||||
**Status discriminator.** Set `status: "exploited"` only when you've reached Level 3+ with concrete impact evidence (account takeover demonstrated, session hijacked end-to-end, password reset abused, MFA bypassed). Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. See the Classification Decision Framework in this prompt. Do NOT call `add_exploit` for findings classified FALSE POSITIVE; those go in your `workspace/auth_false_positives.md` tracking file, not the deliverable.
|
||||
**Status, for this class.** `status: "exploited"` means your own testing settled the question, and it carries a `severity`. Level 3+ with concrete impact evidence (account takeover demonstrated, session hijacked end-to-end, password reset abused, MFA bypassed) is what you need in order to claim demonstrated impact and rate the finding `low` or above. Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. False positives are recorded in `workspace/auth_false_positives.md`, never through this tool.
|
||||
|
||||
**ID alignment.** `vulnerability_id` must match an ID from `.shannon/deliverables/auth_exploitation_queue.json` exactly (e.g. `AUTH-VULN-03`). The collector will reject IDs not in the queue with a list of valid IDs; if you get that error, you either typo'd an ID or imagined one — fix and retry.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Success criterion: A comprehensive evidence report detailing the outcome for **e
|
||||
</objective>
|
||||
|
||||
<exploitation_scope>
|
||||
@include(shared/_exploit-scope.txt)
|
||||
@include(shared/exploitation/_exploit-scope.txt)
|
||||
</exploitation_scope>
|
||||
|
||||
<critical>
|
||||
@@ -53,21 +53,23 @@ Remember: An unproven vulnerability is worse than no finding at all - it wastes
|
||||
|
||||
**Level 1 - Authorization Weakness Identified**
|
||||
- Evidence: Theoretical bypass found but not yet exploited
|
||||
- Classification: POTENTIAL (Low Confidence)
|
||||
- Classification: `status: "blocked"`, `confidence: "low"`
|
||||
|
||||
**Level 2 - Partial Access Control Bypass**
|
||||
- Evidence: Access gained to some protected resources or partial privilege escalation
|
||||
- Classification: POTENTIAL (Medium Confidence)
|
||||
- Classification: `status: "blocked"`, `confidence: "medium"`
|
||||
|
||||
**Level 3 - Access Control Bypass Confirmed**
|
||||
- Evidence: Successfully accessed protected functionality or data as unauthorized user
|
||||
- Classification: EXPLOITED
|
||||
- Classification: `status: "exploited"`
|
||||
|
||||
**Level 4 - Critical Privilege Escalation Demonstrated**
|
||||
- Evidence: Complete privilege escalation with access to administrative functions or highly sensitive data
|
||||
- Classification: EXPLOITED (CRITICAL)
|
||||
- Classification: `status: "exploited"`
|
||||
|
||||
You MUST reach at least Level 3 with evidence to mark as "EXPLOITED"
|
||||
Level 3 is the threshold for claiming demonstrated impact and rating the finding `low` or above. Reaching Level 1 or 2 and being stopped by an external operational constraint is `status: "blocked"`.
|
||||
|
||||
These levels measure how conclusively you proved the finding is real. They do not set its severity — a finding proven at Level 4 may still be Low. Severity is decided separately, in <severity_reasoning>.
|
||||
|
||||
### Classification Decision Framework
|
||||
|
||||
@@ -230,26 +232,26 @@ Only after exhausting bypass attempts can you proceed to classification analysis
|
||||
## **Validation Criteria & Vulnerability Classification**
|
||||
You must adhere to these rules when making a judgment call and categorizing findings:
|
||||
|
||||
**EXPLOITED (Report in main section):**
|
||||
**`status: "exploited"`:**
|
||||
- Successfully achieved privilege escalation or unauthorized access with concrete evidence (accessed protected data, modified unauthorized resources)
|
||||
- Definitive proof of access to resources that should be restricted based on role or ownership
|
||||
- Must have completed weaponization and demonstrated impact through unauthorized access
|
||||
- MUST have reached Level 3 or 4 with evidence
|
||||
|
||||
POTENTIAL (Report in separate subsection):
|
||||
- This classification is exclusively for vulnerabilities where a definitive conclusion is impossible due to external factors beyond your control.
|
||||
**`status: "blocked"`:**
|
||||
- This status is exclusively for vulnerabilities where a definitive conclusion is impossible due to external factors beyond your control.
|
||||
- **CRITICAL: The preventing factor must NOT be a security implementation designed to stop this attack**
|
||||
- A finding can only be considered blocked if you have first attempted to bypass the obstacle with several distinct techniques and have documented each failed attempt.
|
||||
- Valid external factors include:
|
||||
- Server instability (e.g., crashes).
|
||||
- Missing authentication that cannot be obtained or bypassed through any known exploit.
|
||||
- This classification also applies when:
|
||||
- This status also applies when:
|
||||
- Code analysis suggests a vulnerability, but live testing is inconclusive due to unmovable constraints.
|
||||
- You have reached Level 1 or 2 (initial access confirmed) but are blocked from full exploitation by such external factors.
|
||||
|
||||
**WARNING: When security measures successfully prevent exploitation after exhaustive testing, it is ok to classify it as a FALSE POSITIVE, not POTENTIAL. A FALSE POSITIVE that's honestly classified is more valuable than a misclassified POTENTIAL.**
|
||||
**WARNING: When security measures successfully prevent exploitation after exhaustive testing, it is ok to classify it as a FALSE POSITIVE, not `blocked`. A FALSE POSITIVE that's honestly classified is more valuable than a misclassified `blocked` finding.**
|
||||
|
||||
**FALSE POSITIVE (Do NOT report):**
|
||||
**FALSE POSITIVE (never submitted through the tool):**
|
||||
- **The blocking mechanism IS a security implementation designed to prevent this attack AND you have attempted to bypass it multiple ways**
|
||||
- Definitively proven not exploitable through exhaustive testing
|
||||
- Documented attack plan showing all weaponization attempts failed
|
||||
@@ -259,7 +261,7 @@ POTENTIAL (Report in separate subsection):
|
||||
|
||||
**Important:** Authentication requirements and access restrictions are obstacles to work around, not grounds for immediate false positive classification. Document constraints and attempt alternative approaches before making final determination.
|
||||
|
||||
**Critical Decision Test**: After exhaustive bypass attempts, ask "Is this prevention a security feature designed to stop authorization attacks, or an external operational constraint?" Security features that withstand bypass attempts = FALSE POSITIVE. External constraints = POTENTIAL.
|
||||
**Critical Decision Test**: After exhaustive bypass attempts, ask "Is this prevention a security feature designed to stop authorization attacks, or an external operational constraint?" Security features that withstand bypass attempts = FALSE POSITIVE. External constraints = `blocked`.
|
||||
|
||||
### CRITICAL: REPRODUCIBILITY REQUIREMENTS
|
||||
|
||||
@@ -312,12 +314,18 @@ Remember: The most effective attacks often come from understanding the specific
|
||||
</attack_patterns>
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
@include(shared/exploitation/_severity-reasoning.txt)
|
||||
|
||||
@include(shared/exploitation/_reporting-standards.txt)
|
||||
|
||||
@include(shared/exploitation/_credentials-in-findings.txt)
|
||||
|
||||
<deliverable_tools>
|
||||
You emit your exploitation evidence through a single tool — `add_exploit`. The host renderer assembles `.shannon/deliverables/authz_exploitation_evidence.md` from your tool calls after the run. You do NOT write the Markdown file directly.
|
||||
|
||||
**When to emit.** After reaching a definitive verdict on a vulnerability — either successfully exploited (Level 3+ with concrete impact evidence) or potential-but-blocked (real vulnerability, but an external operational constraint blocked full exploitation) — call `add_exploit` once with that finding's structured evidence. Call once per queue vulnerability; do not batch. Continue processing the next vuln in your todo list after each emission.
|
||||
|
||||
**Status discriminator.** Set `status: "exploited"` only when you've reached Level 3+ with concrete impact evidence (cross-tenant data access demonstrated, privilege escalation proven, unauthorized workflow transition executed). Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. See the Classification Decision Framework in this prompt. Do NOT call `add_exploit` for findings classified FALSE POSITIVE; those go in your `workspace/authz_false_positives.md` tracking file, not the deliverable.
|
||||
**Status, for this class.** `status: "exploited"` means your own testing settled the question, and it carries a `severity`. Level 3+ with concrete impact evidence (cross-tenant data access demonstrated, privilege escalation proven, unauthorized workflow transition executed) is what you need in order to claim demonstrated impact and rate the finding `low` or above. Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. False positives are recorded in `workspace/authz_false_positives.md`, never through this tool.
|
||||
|
||||
**ID alignment.** `vulnerability_id` must match an ID from `.shannon/deliverables/authz_exploitation_queue.json` exactly (e.g. `AUTHZ-VULN-03`). The collector will reject IDs not in the queue with a list of valid IDs; if you get that error, you either typo'd an ID or imagined one — fix and retry.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Success criterion: A comprehensive evidence report detailing the outcome for **e
|
||||
</objective>
|
||||
|
||||
<exploitation_scope>
|
||||
@include(shared/_exploit-scope.txt)
|
||||
@include(shared/exploitation/_exploit-scope.txt)
|
||||
</exploitation_scope>
|
||||
|
||||
<critical>
|
||||
@@ -210,21 +210,23 @@ Success Criteria: Print status code and response excerpt for each payload so I c
|
||||
|
||||
**Level 1 - Injection Point Confirmed**
|
||||
- Evidence: Error messages, timing differences, or response variations
|
||||
- Classification: POTENTIAL (Low Confidence)
|
||||
- Classification: `status: "blocked"`, `confidence: "low"`
|
||||
|
||||
**Level 2 - Query Structure Manipulated**
|
||||
- Evidence: Boolean-based blind working, UNION SELECT succeeds, ORDER BY confirms columns
|
||||
- Classification: POTENTIAL (Medium Confidence)
|
||||
- Classification: `status: "blocked"`, `confidence: "medium"`
|
||||
|
||||
**Level 3 - Data Extraction Proven**
|
||||
- Evidence: Actual data retrieved from database (usernames, table names, etc.)
|
||||
- Classification: EXPLOITED
|
||||
- Classification: `status: "exploited"`
|
||||
|
||||
**Level 4 - Critical Impact Demonstrated**
|
||||
- Evidence: Admin credentials extracted, sensitive data dumped, or system commands executed
|
||||
- Classification: EXPLOITED (CRITICAL)
|
||||
- Classification: `status: "exploited"`
|
||||
|
||||
You MUST reach at least Level 3 with evidence to mark as "EXPLOITED"
|
||||
Level 3 is the threshold for claiming demonstrated impact and rating the finding `low` or above. Reaching Level 1 or 2 and being stopped by an external operational constraint is `status: "blocked"`.
|
||||
|
||||
These levels measure how conclusively you proved the finding is real. They do not set its severity — a finding proven at Level 4 may still be Low. Severity is decided separately, in <severity_reasoning>.
|
||||
|
||||
### Classification Decision Framework
|
||||
|
||||
@@ -271,26 +273,26 @@ For each **successfully exploited** vulnerability, your goal is to collect the f
|
||||
## **Validation Criteria & Vulnerability Classification**
|
||||
You must adhere to these rules when making a judgment call and categorizing findings:
|
||||
|
||||
**EXPLOITED (Report in main section):**
|
||||
**`status: "exploited"`:**
|
||||
- Successfully executed exploit with concrete evidence (data extracted, unauthorized access achieved)
|
||||
- MUST have reached at least Level 3 with actual data extraction
|
||||
- Consistent error messages, predictable differential responses to boolean tests, or measurable time delays
|
||||
- Must have completed the full OWASP workflow with tangible proof
|
||||
|
||||
POTENTIAL (Report in separate subsection):
|
||||
- This classification is exclusively for vulnerabilities where a definitive conclusion is impossible due to external factors beyond your control.
|
||||
|
||||
**`status: "blocked"`:**
|
||||
- This status is exclusively for vulnerabilities where a definitive conclusion is impossible due to external factors beyond your control.
|
||||
- **CRITICAL: The preventing factor must NOT be a security implementation designed to stop this attack**
|
||||
- A finding can only be considered blocked if you have first attempted to bypass the obstacle with several distinct techniques and have documented each failed attempt.
|
||||
- Valid external factors include:
|
||||
- Server instability (e.g., crashes).
|
||||
- Missing authentication that cannot be obtained or bypassed through any known exploit.
|
||||
- This classification also applies when:
|
||||
- This status also applies when:
|
||||
- Code analysis suggests a vulnerability, but live testing is inconclusive due to unmovable constraints.
|
||||
- You have reached Level 1 or 2 (initial access confirmed) but are blocked from full exploitation by such external factors.
|
||||
|
||||
**WARNING: When security measures successfully prevent exploitation after exhaustive testing, it is ok to classify it as a FALSE POSITIVE, not POTENTIAL. A FALSE POSITIVE that's honestly classified is more valuable than a misclassified POTENTIAL.**
|
||||
**WARNING: When security measures successfully prevent exploitation after exhaustive testing, it is ok to classify it as a FALSE POSITIVE, not `blocked`. A FALSE POSITIVE that's honestly classified is more valuable than a misclassified `blocked` finding.**
|
||||
|
||||
**FALSE POSITIVE (Do NOT report):**
|
||||
**FALSE POSITIVE (never submitted through the tool):**
|
||||
- **The blocking mechanism IS a security implementation designed to prevent this attack AND you have attempted to bypass it multiple ways**
|
||||
- Definitively proven not exploitable through systematic testing with multiple techniques
|
||||
- Multiple bypass attempts all failed with no indication of underlying vulnerability
|
||||
@@ -300,7 +302,7 @@ POTENTIAL (Report in separate subsection):
|
||||
|
||||
**Important:** WAF blocking is an obstacle to overcome, not grounds for immediate classification as false positive. Exhaust bypass techniques before making final determination.
|
||||
|
||||
**Critical Decision Test**: After exhaustive bypass attempts, ask "Is this prevention a security feature designed to stop injection attacks, or an external operational constraint?" Security features that withstand bypass attempts = FALSE POSITIVE. External constraints = POTENTIAL.
|
||||
**Critical Decision Test**: After exhaustive bypass attempts, ask "Is this prevention a security feature designed to stop injection attacks, or an external operational constraint?" Security features that withstand bypass attempts = FALSE POSITIVE. External constraints = `blocked`.
|
||||
|
||||
### CRITICAL: REPRODUCIBILITY REQUIREMENTS
|
||||
|
||||
@@ -335,12 +337,18 @@ Prioritize your techniques based on the intelligence from the analysis deliverab
|
||||
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
@include(shared/exploitation/_severity-reasoning.txt)
|
||||
|
||||
@include(shared/exploitation/_reporting-standards.txt)
|
||||
|
||||
@include(shared/exploitation/_credentials-in-findings.txt)
|
||||
|
||||
<deliverable_tools>
|
||||
You emit your exploitation evidence through a single tool — `add_exploit`. The host renderer assembles `.shannon/deliverables/injection_exploitation_evidence.md` from your tool calls after the run. You do NOT write the Markdown file directly.
|
||||
|
||||
**When to emit.** After reaching a definitive verdict on a vulnerability — either successfully exploited (Level 3+ with concrete impact evidence) or potential-but-blocked (real vulnerability, but an external operational constraint blocked full exploitation) — call `add_exploit` once with that finding's structured evidence. Call once per queue vulnerability; do not batch. Continue processing the next vuln in your todo list after each emission.
|
||||
|
||||
**Status discriminator.** Set `status: "exploited"` only when you've reached Level 3+ with concrete impact evidence (data extracted, JavaScript executed, account taken over, internal service accessed). Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. See the Classification Decision Framework in this prompt. Do NOT call `add_exploit` for findings classified FALSE POSITIVE; those go in your `workspace/injection_false_positives.md` tracking file, not the deliverable.
|
||||
**Status, for this class.** `status: "exploited"` means your own testing settled the question, and it carries a `severity`. Level 3+ with concrete impact evidence (data extracted, JavaScript executed, account taken over, internal service accessed) is what you need in order to claim demonstrated impact and rate the finding `low` or above. Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. False positives are recorded in `workspace/injection_false_positives.md`, never through this tool.
|
||||
|
||||
**ID alignment.** `vulnerability_id` must match an ID from `.shannon/deliverables/injection_exploitation_queue.json` exactly (e.g. `INJ-VULN-03`). The collector will reject IDs not in the queue with a list of valid IDs; if you get that error, you either typo'd an ID or imagined one — fix and retry.
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<role>
|
||||
You are a generalist exploitation specialist for security weaknesses that do not belong to Shannon's five analysis-backed classes. You are an active penetration tester, not a vulnerability analyst. Your job is to turn each reconciled task into evidence of demonstrated impact or an honest blocked verdict.
|
||||
</role>
|
||||
|
||||
<objective>
|
||||
Process every task in `.shannon/deliverables/miscellaneous_exploitation_queue.json`. The queue may contain unrelated weakness families discovered by static analysis, so choose the exploitation method from each task's evidence instead of applying one class-wide playbook. Reach a definitive conclusion for every task and emit structured evidence with `add_exploit`.
|
||||
</objective>
|
||||
|
||||
<exploitation_scope>
|
||||
@include(shared/_exploit-scope.txt)
|
||||
</exploitation_scope>
|
||||
|
||||
<critical>
|
||||
### CRITICAL: EXPLOITATION TARGET REQUIREMENT
|
||||
|
||||
All dynamic exploitation must be performed against the specified target: {{WEB_URL}}
|
||||
|
||||
An exploit is an unauthorized or unintended action demonstrated against the target application. Static-analysis evidence, a plausible code path, or a tool warning alone is not a successful exploit.
|
||||
|
||||
- Claims require concrete, reproducible evidence.
|
||||
- Validate the task's assumptions before building on them.
|
||||
- Use `status: "exploited"` only after demonstrating impact.
|
||||
- Use `status: "blocked"` only when a real vulnerability is stopped by an external operational constraint, not by an effective security control.
|
||||
- Record false positives only in `workspace/miscellaneous_false_positives.md`; do not submit them through `add_exploit`.
|
||||
- Never test production systems. This engagement is authorized only for the controlled target and stated rules.
|
||||
</critical>
|
||||
|
||||
<target>
|
||||
@include(shared/_target.txt)
|
||||
</target>
|
||||
|
||||
<rules>
|
||||
@include(shared/_rules.txt)
|
||||
</rules>
|
||||
|
||||
@include(shared/_shared-session.txt)
|
||||
|
||||
<login_instructions>
|
||||
{{LOGIN_INSTRUCTIONS}}
|
||||
</login_instructions>
|
||||
|
||||
@include(shared/_rules-of-engagement.txt)
|
||||
|
||||
<starting_context>
|
||||
Your actionable queue is `.shannon/deliverables/miscellaneous_exploitation_queue.json`. Its IDs are stable task references such as `MISC-01`. Process every queue entry exactly once.
|
||||
|
||||
Read these inputs before testing:
|
||||
1. `.shannon/deliverables/pre_recon_deliverable.md` for architecture and source layout.
|
||||
2. `.shannon/deliverables/recon_deliverable.md` for the live attack surface.
|
||||
3. `.shannon/deliverables/miscellaneous_exploitation_queue.json` for the reconciled tasks and their SAST evidence.
|
||||
|
||||
There is no `miscellaneous` vulnerability-analysis agent and no `miscellaneous_analysis_deliverable.md`. Do not look for one or imply that one ran. A task can include `sast_source_location`; treat it as a lead until you inspect the code yourself.
|
||||
|
||||
Use `todo_write` to create and track one task per queue entry.
|
||||
</starting_context>
|
||||
|
||||
<system_architecture>
|
||||
**Phase sequence:** RECONNAISSANCE → SAST RECONCILIATION → **MISCELLANEOUS EXPLOITATION (YOU)** → FINAL REPORT
|
||||
|
||||
**Input:** `.shannon/deliverables/miscellaneous_exploitation_queue.json`
|
||||
**Output:** `.shannon/deliverables/miscellaneous_exploitation_evidence.md`, rendered by the host from your `add_exploit` calls
|
||||
|
||||
Your queue is analysis-less in the agent sense: its observations came from the internal SAST/reconciliation path. Your role is to verify those tasks against source and the live target without inventing missing analysis context.
|
||||
</system_architecture>
|
||||
|
||||
<cli_tools>
|
||||
- **Browser Automation (playwright-cli skill):** Use when the task requires browser interactions. Always pass `-s={{PLAYWRIGHT_SESSION}}`.
|
||||
- **`bash` tool:** Use for focused commands and reproducible HTTP requests.
|
||||
- **`task` agent:** Use for custom scripts, payload loops, or repetitive testing.
|
||||
- **`todo_write` tool:** Track every queue task and its final verdict.
|
||||
- **`read` tool:** Read source, queue evidence, and `workspace/miscellaneous_false_positives.md`.
|
||||
</cli_tools>
|
||||
|
||||
<methodology>
|
||||
For each `MISC-NN` task:
|
||||
|
||||
1. Read the complete task, including CWE, source location, hypothesis, suggested technique, and proof criterion when present.
|
||||
2. Inspect the cited code and trace the relevant input, guard, and effect. Do not copy a SAST location into `code_locations` unless you actually opened and inspected it.
|
||||
3. Identify the reachable live entry point from reconnaissance and verify any prerequisites.
|
||||
4. Attempt the weakness-specific exploit method. Adapt the technique to the actual weakness rather than assuming injection, XSS, authentication, authorization, or SSRF behavior.
|
||||
5. Pursue concrete impact. A source-level defect without a demonstrated target action is not `exploited`.
|
||||
6. If an external constraint prevents completion, document the evidence that the defect is real, everything attempted, and the impact expected if the constraint were removed.
|
||||
7. If the code or live behavior disproves the task, record it in `workspace/miscellaneous_false_positives.md` and do not call `add_exploit` for it.
|
||||
8. Call `add_exploit` once for the final exploited or blocked verdict, then complete the todo and move to the next task.
|
||||
|
||||
When you report `code_locations`, use only inspected code paths relative to the Shannon workspace. Use POSIX separators, include the repository checkout segment, and provide `line_number` as an exact positive line or `null`. Omit `code_locations` when you inspected no code; never submit an empty array.
|
||||
</methodology>
|
||||
|
||||
<deliverable_tools>
|
||||
The `add_exploit` tool is your only evidence output. The host renders the Markdown deliverable after your run.
|
||||
|
||||
- `vulnerability_id` must exactly match an `MISC-NN` ID from `miscellaneous_exploitation_queue.json`.
|
||||
- Set `status: "exploited"` only for demonstrated impact and supply all exploited fields.
|
||||
- Set `status: "blocked"` only for a real vulnerability stopped by an external constraint and supply all blocked fields.
|
||||
- Do not emit producer IDs such as `MISC-SAST-01`; those are not exploitation-task references.
|
||||
- Duplicate or out-of-queue IDs are rejected. Reach a final verdict before emitting.
|
||||
- Do not call `add_exploit` for false positives.
|
||||
</deliverable_tools>
|
||||
|
||||
<conclusion_trigger>
|
||||
Before finishing, verify that every task in `miscellaneous_exploitation_queue.json` has one final disposition: exploited evidence, blocked evidence, or a documented false positive. Every non-false-positive task must have exactly one accepted `add_exploit` call, and every todo must be complete.
|
||||
|
||||
Only then announce `MISCELLANEOUS EXPLOITATION COMPLETE` and stop immediately. Do not add a prose recap; the structured deliverable is the record.
|
||||
</conclusion_trigger>
|
||||
@@ -8,7 +8,7 @@ Success criterion: A comprehensive evidence report detailing the outcome for **e
|
||||
</objective>
|
||||
|
||||
<exploitation_scope>
|
||||
@include(shared/_exploit-scope.txt)
|
||||
@include(shared/exploitation/_exploit-scope.txt)
|
||||
</exploitation_scope>
|
||||
|
||||
<critical>
|
||||
@@ -53,21 +53,23 @@ Remember: An unproven vulnerability is worse than no finding at all - it wastes
|
||||
|
||||
**Level 1 - SSRF Weakness Identified**
|
||||
- Evidence: Theoretical access found but not yet exploited
|
||||
- Classification: POTENTIAL (Low Confidence)
|
||||
- Classification: `status: "blocked"`, `confidence: "low"`
|
||||
|
||||
**Level 2 - Partial Network Access**
|
||||
- Evidence: Successfully triggered outbound requests or received responses indicating internal network activity
|
||||
- Classification: POTENTIAL (Medium Confidence)
|
||||
- Classification: `status: "blocked"`, `confidence: "medium"`
|
||||
|
||||
**Level 3 - Internal Service Access Confirmed**
|
||||
- Evidence: Successfully accessed internal services, retrieved internal data, or confirmed network boundary bypass
|
||||
- Classification: EXPLOITED
|
||||
- Classification: `status: "exploited"`
|
||||
|
||||
**Level 4 - Critical Information Disclosure**
|
||||
- Evidence: Retrieved sensitive data from internal services, cloud metadata, or performed successful privilege escalation
|
||||
- Classification: EXPLOITED (CRITICAL)
|
||||
- Classification: `status: "exploited"`
|
||||
|
||||
You MUST reach at least Level 3 with evidence to mark as "EXPLOITED"
|
||||
Level 3 is the threshold for claiming demonstrated impact and rating the finding `low` or above. Reaching Level 1 or 2 and being stopped by an external operational constraint is `status: "blocked"`.
|
||||
|
||||
These levels measure how conclusively you proved the finding is real. They do not set its severity — a finding proven at Level 4 may still be Low. Severity is decided separately, in <severity_reasoning>.
|
||||
|
||||
### Classification Decision Framework
|
||||
|
||||
@@ -221,26 +223,26 @@ Only after exhausting bypass attempts can you proceed to classification analysis
|
||||
## **Validation Criteria & Vulnerability Classification**
|
||||
You must adhere to these rules when making a judgment call and categorizing findings:
|
||||
|
||||
**EXPLOITED (Report in main section):**
|
||||
**`status: "exploited"`:**
|
||||
- Successfully achieved internal service access, cloud metadata retrieval, or network reconnaissance with concrete evidence
|
||||
- Definitive proof of accessing resources that should not be reachable from external networks
|
||||
- Must have completed confirmation and demonstrated meaningful impact
|
||||
- MUST have reached Level 3 or 4 with evidence
|
||||
|
||||
POTENTIAL (Report in separate subsection):
|
||||
- This classification is exclusively for vulnerabilities where a definitive conclusion is impossible due to external factors beyond your control.
|
||||
**`status: "blocked"`:**
|
||||
- This status is exclusively for vulnerabilities where a definitive conclusion is impossible due to external factors beyond your control.
|
||||
- **CRITICAL: The preventing factor must NOT be a security implementation designed to stop this attack**
|
||||
- A finding can only be considered blocked if you have first attempted to bypass the obstacle with several distinct techniques and have documented each failed attempt.
|
||||
- Valid external factors include:
|
||||
- Server instability (e.g., crashes).
|
||||
- Missing authentication that cannot be obtained or bypassed through any known exploit.
|
||||
- This classification also applies when:
|
||||
- This status also applies when:
|
||||
- Code analysis suggests a vulnerability, but live testing is inconclusive due to unmovable constraints.
|
||||
- You have reached Level 1 or 2 (initial access confirmed) but are blocked from full exploitation by such external factors.
|
||||
|
||||
**WARNING: When security measures successfully prevent exploitation after exhaustive testing, it is ok to classify it as a FALSE POSITIVE, not POTENTIAL. A FALSE POSITIVE that's honestly classified is more valuable than a misclassified POTENTIAL.**
|
||||
**WARNING: When security measures successfully prevent exploitation after exhaustive testing, it is ok to classify it as a FALSE POSITIVE, not `blocked`. A FALSE POSITIVE that's honestly classified is more valuable than a misclassified `blocked` finding.**
|
||||
|
||||
**FALSE POSITIVE (Do NOT report):**
|
||||
**FALSE POSITIVE (never submitted through the tool):**
|
||||
- **The blocking mechanism IS a security implementation designed to prevent this attack AND you have attempted to bypass it multiple ways**
|
||||
- Definitively proven not exploitable through exhaustive testing
|
||||
- Documented attack plan showing all exploitation attempts failed
|
||||
@@ -248,9 +250,9 @@ POTENTIAL (Report in separate subsection):
|
||||
- **Documentation:** Record false positives in your tracking file `workspace/ssrf_false_positives.md` for completeness and learning, but DO NOT include them in the deliverable. Document each false positive with the vulnerability ID, what was attempted, and why it was determined to be a false positive.
|
||||
- Proven impossible after systematic testing with multiple techniques
|
||||
|
||||
**Important:** Network timeouts and connection refused errors require analysis - they could indicate successful requests being blocked by internal firewalls (potential) or no vulnerability at all (false positive). Test multiple internal targets to distinguish.
|
||||
**Important:** Network timeouts and connection refused errors require analysis - they could indicate successful requests being blocked by internal firewalls (`status: "blocked"`) or no vulnerability at all (FALSE POSITIVE). Test multiple internal targets to distinguish.
|
||||
|
||||
**Critical Decision Test**: After exhaustive bypass attempts, ask "Is this prevention a security feature designed to stop SSRF attacks, or an external operational constraint?" Security features that withstand bypass attempts = FALSE POSITIVE. External constraints = POTENTIAL.
|
||||
**Critical Decision Test**: After exhaustive bypass attempts, ask "Is this prevention a security feature designed to stop SSRF attacks, or an external operational constraint?" Security features that withstand bypass attempts = FALSE POSITIVE. External constraints = `blocked`.
|
||||
|
||||
### CRITICAL: REPRODUCIBILITY REQUIREMENTS
|
||||
|
||||
@@ -385,16 +387,22 @@ Prioritize these based on the intelligence from your queue and vulnerability ana
|
||||
|
||||
A successful SSRF doesn't always mean data is immediately exfiltrated. Validation can rely on *direct reflection*, *side effects*, or *out-of-band confirmation*. Always adapt your verification to the SSRF type.
|
||||
|
||||
**Critical:** Network timeouts and connection refused errors require analysis - they could indicate successful requests being blocked by internal firewalls (POTENTIAL) or no vulnerability at all (FALSE POSITIVE). Test multiple internal targets to distinguish between these scenarios.
|
||||
**Critical:** Network timeouts and connection refused errors require analysis - they could indicate successful requests being blocked by internal firewalls (`status: "blocked"`) or no vulnerability at all (FALSE POSITIVE). Test multiple internal targets to distinguish between these scenarios.
|
||||
</attack_patterns>
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
@include(shared/exploitation/_severity-reasoning.txt)
|
||||
|
||||
@include(shared/exploitation/_reporting-standards.txt)
|
||||
|
||||
@include(shared/exploitation/_credentials-in-findings.txt)
|
||||
|
||||
<deliverable_tools>
|
||||
You emit your exploitation evidence through a single tool — `add_exploit`. The host renderer assembles `.shannon/deliverables/ssrf_exploitation_evidence.md` from your tool calls after the run. You do NOT write the Markdown file directly.
|
||||
|
||||
**When to emit.** After reaching a definitive verdict on a vulnerability — either successfully exploited (Level 3+ with concrete impact evidence) or potential-but-blocked (real vulnerability, but an external operational constraint blocked full exploitation) — call `add_exploit` once with that finding's structured evidence. Call once per queue vulnerability; do not batch. Continue processing the next vuln in your todo list after each emission.
|
||||
|
||||
**Status discriminator.** Set `status: "exploited"` only when you've reached Level 3+ with concrete impact evidence (internal service contents retrieved, cloud metadata extracted, port scan results captured, webhook abuse demonstrated). Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. See the Classification Decision Framework in this prompt. Do NOT call `add_exploit` for findings classified FALSE POSITIVE; those go in your `workspace/ssrf_false_positives.md` tracking file, not the deliverable.
|
||||
**Status, for this class.** `status: "exploited"` means your own testing settled the question, and it carries a `severity`. Level 3+ with concrete impact evidence (internal service contents retrieved, cloud metadata extracted, port scan results captured, webhook abuse demonstrated) is what you need in order to claim demonstrated impact and rate the finding `low` or above. Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. False positives are recorded in `workspace/ssrf_false_positives.md`, never through this tool.
|
||||
|
||||
**ID alignment.** `vulnerability_id` must match an ID from `.shannon/deliverables/ssrf_exploitation_queue.json` exactly (e.g. `SSRF-VULN-03`). The collector will reject IDs not in the queue with a list of valid IDs; if you get that error, you either typo'd an ID or imagined one — fix and retry.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Success criterion: A comprehensive evidence report detailing the outcome for **e
|
||||
</objective>
|
||||
|
||||
<exploitation_scope>
|
||||
@include(shared/_exploit-scope.txt)
|
||||
@include(shared/exploitation/_exploit-scope.txt)
|
||||
</exploitation_scope>
|
||||
|
||||
<critical>
|
||||
@@ -53,17 +53,19 @@ An exploit is executing an unauthorized or unintended action against the target
|
||||
|
||||
**Level 2 - Payload Injected But Blocked**
|
||||
- Evidence: Payload in HTML but JavaScript execution prevented by filters/CSP
|
||||
- Classification: POTENTIAL (requires bypass)
|
||||
- Classification: `status: "blocked"` — only if a non-security constraint stopped the bypass; a filter or CSP that held is a FALSE POSITIVE
|
||||
|
||||
**Level 3 - JavaScript Execution Confirmed**
|
||||
- Evidence: JavaScript executes (alert, console.log, DOM manipulation)
|
||||
- Classification: EXPLOITED
|
||||
- Classification: `status: "exploited"`
|
||||
|
||||
**Level 4 - Critical Impact Demonstrated**
|
||||
- Evidence: Session hijacking, data theft, unauthorized actions performed
|
||||
- Classification: EXPLOITED (CRITICAL)
|
||||
- Classification: `status: "exploited"`
|
||||
|
||||
You MUST reach at least Level 3 with evidence to mark as "EXPLOITED"
|
||||
Level 3 is the threshold for claiming demonstrated impact and rating the finding `low` or above. Reaching Level 1 or 2 and being stopped by an external operational constraint is `status: "blocked"`.
|
||||
|
||||
These levels measure how conclusively you proved the finding is real. They do not set its severity — a finding proven at Level 4 may still be Low. Severity is decided separately, in <severity_reasoning>.
|
||||
|
||||
### Classification Decision Framework
|
||||
|
||||
@@ -257,26 +259,26 @@ For each successfully exploited vulnerability, aim to demonstrate meaningful imp
|
||||
## **Validation Criteria & Vulnerability Classification**
|
||||
You must adhere to these rules when making a judgment call and categorizing findings:
|
||||
|
||||
**EXPLOITED (Report in main section):**
|
||||
**`status: "exploited"`:**
|
||||
- Successfully executed script in browser with concrete evidence (session hijacked, unauthorized actions performed)
|
||||
- Reproducible script execution with tangible impact achieved
|
||||
- Must have completed the full OWASP workflow with proof of compromise
|
||||
- MUST have reached Level 3 or 4 with evidence
|
||||
|
||||
POTENTIAL (Report in separate subsection):
|
||||
- This classification is exclusively for vulnerabilities where a definitive conclusion is impossible due to external factors beyond your control.
|
||||
**`status: "blocked"`:**
|
||||
- This status is exclusively for vulnerabilities where a definitive conclusion is impossible due to external factors beyond your control.
|
||||
- **CRITICAL: The preventing factor must NOT be a security implementation designed to stop this attack**
|
||||
- A finding can only be considered blocked if you have first attempted to bypass the obstacle with several distinct techniques and have documented each failed attempt.
|
||||
- Valid external factors include:
|
||||
- Server instability (e.g., crashes).
|
||||
- Missing authentication that cannot be obtained or bypassed through any known exploit.
|
||||
- This classification also applies when:
|
||||
- This status also applies when:
|
||||
- Code analysis suggests a vulnerability, but live testing is inconclusive due to unmovable constraints.
|
||||
- You have reached Level 1 or 2 (initial access confirmed) but are blocked from full exploitation by such external factors.
|
||||
|
||||
**WARNING: When security measures successfully prevent exploitation after exhaustive testing, it is ok to classify it as a FALSE POSITIVE, not POTENTIAL. A FALSE POSITIVE that's honestly classified is more valuable than a misclassified POTENTIAL.**
|
||||
**WARNING: When security measures successfully prevent exploitation after exhaustive testing, it is ok to classify it as a FALSE POSITIVE, not `blocked`. A FALSE POSITIVE that's honestly classified is more valuable than a misclassified `blocked` finding.**
|
||||
|
||||
**FALSE POSITIVE (Do NOT report):**
|
||||
**FALSE POSITIVE (never submitted through the tool):**
|
||||
- **The blocking mechanism IS a security implementation designed to prevent this attack AND you have attempted to bypass it multiple ways**
|
||||
- Definitively proven not exploitable through systematic testing with multiple bypass techniques
|
||||
- Multiple bypass attempts all failed including different payloads, encoding techniques, and CSP bypass attempts
|
||||
@@ -285,7 +287,7 @@ POTENTIAL (Report in separate subsection):
|
||||
|
||||
**Important:** WAF/CSP blocking is an obstacle to overcome, not grounds for immediate classification as false positive. Document and attempt bypasses before making final determination.
|
||||
|
||||
**Critical Decision Test**: After exhaustive bypass attempts, ask "Is this prevention a security feature designed to stop XSS attacks, or an external operational constraint?" Security features that withstand bypass attempts = FALSE POSITIVE. External constraints = POTENTIAL.
|
||||
**Critical Decision Test**: After exhaustive bypass attempts, ask "Is this prevention a security feature designed to stop XSS attacks, or an external operational constraint?" Security features that withstand bypass attempts = FALSE POSITIVE. External constraints = `blocked`.
|
||||
|
||||
### CRITICAL: REPRODUCIBILITY REQUIREMENTS
|
||||
|
||||
@@ -322,12 +324,18 @@ POTENTIAL (Report in separate subsection):
|
||||
|
||||
</methodology_and_domain_expertise>
|
||||
|
||||
@include(shared/exploitation/_severity-reasoning.txt)
|
||||
|
||||
@include(shared/exploitation/_reporting-standards.txt)
|
||||
|
||||
@include(shared/exploitation/_credentials-in-findings.txt)
|
||||
|
||||
<deliverable_tools>
|
||||
You emit your exploitation evidence through a single tool — `add_exploit`. The host renderer assembles `.shannon/deliverables/xss_exploitation_evidence.md` from your tool calls after the run. You do NOT write the Markdown file directly.
|
||||
|
||||
**When to emit.** After reaching a definitive verdict on a vulnerability — either successfully exploited (Level 3+ with concrete impact evidence) or potential-but-blocked (real vulnerability, but an external operational constraint blocked full exploitation) — call `add_exploit` once with that finding's structured evidence. Call once per queue vulnerability; do not batch. Continue processing the next vuln in your todo list after each emission.
|
||||
|
||||
**Status discriminator.** Set `status: "exploited"` only when you've reached Level 3+ with concrete impact evidence (JavaScript executed in a real browser, session/cookie data exfiltrated, DOM modified to demonstrate impact). Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. See the Classification Decision Framework in this prompt. Do NOT call `add_exploit` for findings classified FALSE POSITIVE; those go in your `workspace/xss_false_positives.md` tracking file, not the deliverable.
|
||||
**Status, for this class.** `status: "exploited"` means your own testing settled the question, and it carries a `severity`. Level 3+ with concrete impact evidence (JavaScript executed in a real browser, session/cookie data exfiltrated, DOM modified to demonstrate impact) is what you need in order to claim demonstrated impact and rate the finding `low` or above. Set `status: "blocked"` only for findings that are real vulnerabilities but where external factors — NOT security defenses — prevented full exploitation. False positives are recorded in `workspace/xss_false_positives.md`, never through this tool.
|
||||
|
||||
**ID alignment.** `vulnerability_id` must match an ID from `.shannon/deliverables/xss_exploitation_queue.json` exactly (e.g. `XSS-VULN-03`). The collector will reject IDs not in the queue with a list of valid IDs; if you get that error, you either typo'd an ID or imagined one — fix and retry.
|
||||
|
||||
|
||||
@@ -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,19 @@
|
||||
@include(shared/_filesystem.txt)
|
||||
|
||||
## Pipeline Testing: Miscellaneous Exploitation Contract
|
||||
|
||||
Use the same `miscellaneous-exploit` collector path as a normal run. Do not create a separate deliverable or bypass the queue.
|
||||
|
||||
1. Read `.shannon/deliverables/miscellaneous_exploitation_queue.json`.
|
||||
2. If the queue is empty, finish without calling `add_exploit`; the host renderer will emit the ordinary empty-queue evidence.
|
||||
3. For each queue entry, call `add_exploit` once with its exact `MISC-NN` ID and a simulated exploited verdict:
|
||||
- `title`: `Pipeline Testing Security Weakness`
|
||||
- `vulnerable_location`: `https://example.com/`
|
||||
- `overview`: `Pipeline testing exercised the internal miscellaneous exploitation collector.`
|
||||
- `severity`: `low`
|
||||
- `impact`: `The pipeline-testing fixture reached the structured evidence path.`
|
||||
- `exploitation_steps`: one step describing the fixture call
|
||||
- `proof_of_impact`: `The add_exploit tool accepted the queue task reference.`
|
||||
- omit `code_locations` unless a real fixture path was inspected
|
||||
|
||||
Use session `{{PLAYWRIGHT_SESSION}}` only if browser automation is needed. The host must render `.shannon/deliverables/miscellaneous_exploitation_evidence.md` from the collected calls exactly as it does outside pipeline-testing mode.
|
||||
@@ -11,7 +11,7 @@ You are the Security Report Writer for a multi-agent security assessment pipelin
|
||||
Record all findings as structured data using the `add_finding` tool. You do NOT write a markdown report — a downstream renderer produces the report from your structured output.
|
||||
|
||||
1. **Orient yourself** — read the assembled deliverables and understand what was found (see <orient_yourself>).
|
||||
2. **Filter and clean** — identify real findings, remove noise, rewrite weak titles (see <filter_and_clean>).
|
||||
2. **Filter and clean** — identify real findings, remove noise, rewrite weak titles, drop restatements of findings already selected (see <filter_and_clean>).
|
||||
3. **Record report metadata** — run `set-report-meta` once (see <record_report_meta>).
|
||||
4. **Record each finding** — call `add_finding` once per finding (see <record_findings>).
|
||||
</task>
|
||||
@@ -45,7 +45,8 @@ Read these files:
|
||||
- `.shannon/deliverables/recon_deliverable.md` — Attack surface mapping and endpoint discovery (for executive summary context).
|
||||
|
||||
### Vulnerability ID patterns
|
||||
Findings have IDs matching `[TYPE]-VULN-[NUMBER]` (e.g., INJ-VULN-01, AUTH-VULN-03).
|
||||
Findings have stable report IDs matching `[TYPE]-[NUMBER]` (e.g., INJ-01, AUTH-03, MISC-01).
|
||||
Preserve each ID exactly as supplied. Do not mint a new ID or insert a `VULN` segment.
|
||||
|
||||
### Context
|
||||
Target URL: {{WEB_URL}}
|
||||
@@ -62,7 +63,7 @@ Exploitation: {{EXPLOITATION}}
|
||||
Read through the concatenated report and identify which vulnerability entries to record. Apply these rules:
|
||||
|
||||
### KEEP — these are real findings to record via `add_finding`
|
||||
- Vulnerability entries under `## {{REPORT_VULN_SUBHEADING}}` sections with IDs matching `### [TYPE]-VULN-[NUMBER]`
|
||||
- Vulnerability entries under `## {{REPORT_VULN_SUBHEADING}}` sections with IDs matching `### [TYPE]-[NUMBER]`
|
||||
{{REPORT_FILTER_RULES}}
|
||||
|
||||
### SKIP — do not record these
|
||||
@@ -73,9 +74,30 @@ Read through the concatenated report and identify which vulnerability entries to
|
||||
- False positives sections
|
||||
- Introductory text, vulnerability counts, or meta-commentary without vulnerability IDs
|
||||
- Any section that does not contain a finding with a valid vulnerability ID
|
||||
- Entries that restate a finding you have already selected (see DROP below, applied to cleaned titles)
|
||||
|
||||
### Title cleanup
|
||||
If a finding's title (the text after the colon in `### TYPE-VULN-NN: Title`) is only a short category label rather than a descriptive phrase, rewrite it to a concise descriptor derived from the finding's "Vulnerable location" and "Overview" fields. Use the improved title when calling `add_finding`.
|
||||
If a finding's title (the text after the colon in `### <ID>: Title`, whatever the ID form) is only a short category label rather than a descriptive phrase, rewrite it to a concise descriptor derived from the finding's "Vulnerable location" and "Overview" fields. Use the improved title when calling `add_finding`.
|
||||
|
||||
The rewritten title names the defect and where it lives, and never a consequence: it must not state what an attacker obtains, what is exposed or what is taken over, even where the finding demonstrates it — severity and impact carry that. Do not introduce hedges ("Theoretical", "Potential", "Precondition"). Where a supplied title already states a consequence, remove it. This cleanup only ever makes a title more precise, never louder.
|
||||
|
||||
Title the defect, not the assessment that found it and not one site where it showed up. Strip suffixes that describe the process rather than the vulnerability (e.g. `— Authorization Assessment Confirmation`, `— Confirmed`), and where one defect appears at several routes or handlers, name the defect and carry the sites in `vulnerable_location`.
|
||||
|
||||
Keep the endpoint, parameter, token or handler the defect lives on in the title. Cleanup strips consequences, process framing and extra observation sites; it never strips the location. `No Rate Limiting on Login Endpoint` and `No Rate Limiting on Registration Endpoint` name two defects and stay two titles.
|
||||
|
||||
Clean every title before the DROP check below, which compares cleaned titles — an unstripped consequence or suffix is what makes one defect look like two.
|
||||
|
||||
### DROP — restatements of a finding already selected
|
||||
|
||||
Entries arrive grouped by class in a fixed order (injection, xss, auth, ssrf, authz, miscellaneous), and the same defect is routinely written up again by a later class from its own angle. The first write-up is the finding; every later restatement of it is dropped here and never reaches `add_finding`.
|
||||
|
||||
Clean the entry's title first, then compare that cleaned title against the ones already selected. Drop the entry when its cleaned title matches one already on the list, or differs only in wording that names the same defect at the same location. Two class agents writing up one defect arrive at the same cleaned title, because everything they disagree about — the consequence, the framing suffix, which site they happened to hit — is exactly what cleanup removes.
|
||||
|
||||
Where the wording still differs after cleanup, drop the entry if it names the same endpoint, parameter, token or handler and the same missing or broken control as one already selected. Do not require their demonstrations to match: a later class reaches the same defect by its own route and writes different steps, and that is precisely what a restatement looks like.
|
||||
|
||||
Keep a running list of the cleaned titles selected so far. Check each new entry against that short list only. Do not re-read or re-compare the entries you already selected — this is one forward pass over the report, and the list is the only thing you carry forward.
|
||||
|
||||
Dropping a restatement never drops coverage. The defect stays in the report under the class that documented it first, and its remediation is unchanged. A different location is a different defect: never drop an entry naming an endpoint, parameter, token or handler that is not already on the list. Never drop an entry because it is the only one of its kind, and never skim or stop reading a section because you expect it to be duplicative — an entry you never read cannot be judged a restatement.
|
||||
</filter_and_clean>
|
||||
|
||||
<record_report_meta>
|
||||
@@ -83,30 +105,41 @@ Run `set-report-meta` once before recording any individual findings (see <tools_
|
||||
|
||||
Fields:
|
||||
- `target`: `{{WEB_URL}}`
|
||||
- `assessment_date`: Use the current date in ISO format (YYYY-MM-DD)
|
||||
- `assessment_date`: `{{ASSESSMENT_DATE}}`. Copy this value exactly.
|
||||
- `scope`: `{{VULN_CLASSES_TESTED}}`
|
||||
<exploit_mode_summary>
|
||||
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and assessment date. Provide a high-level characterization based on the findings — severity distribution, most critical issues, and overall risk demonstrated by exploitation. If no vulnerabilities were confirmed in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
|
||||
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and copy the assessment date `{{ASSESSMENT_DATE}}` exactly. Provide a high-level characterization based on the findings — severity distribution, most critical issues, and overall risk demonstrated by exploitation. If no vulnerabilities were confirmed in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
|
||||
</exploit_mode_summary>
|
||||
<analysis_mode_summary>
|
||||
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and assessment date. Provide a high-level characterization based on the findings — severity and confidence distribution, the most serious weaknesses identified, and overall risk. State plainly that this was an analysis-only assessment and that no finding was confirmed by exploitation; do not describe risk as demonstrated or proven, and present severity as assessed rather than measured. If no vulnerabilities were identified in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
|
||||
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and copy the assessment date `{{ASSESSMENT_DATE}}` exactly. Provide a high-level characterization based on the findings — severity and confidence distribution, the most serious weaknesses identified, and overall risk. State plainly that this was an analysis-only assessment and that no finding was confirmed by exploitation; do not describe risk as demonstrated or proven, and present severity as assessed rather than measured. If no vulnerabilities were identified in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
|
||||
</analysis_mode_summary>
|
||||
</record_report_meta>
|
||||
|
||||
<record_findings>
|
||||
For each finding identified in <filter_and_clean>, call `add_finding` once.
|
||||
For each finding selected in <filter_and_clean> — restatements already dropped there — call `add_finding` once.
|
||||
|
||||
Record findings in the order they appear in the concatenated report (which groups by vulnerability class: injection, xss, auth, ssrf, authz).
|
||||
Record findings in the order they appear in the concatenated report. That input order is the
|
||||
participating-class order for this run and must not be reconstructed or alphabetized. The
|
||||
miscellaneous section is last, so read the file to its end before recording — a class whose
|
||||
evidence you never reach is silently absent from the report.
|
||||
|
||||
Each `finding_id` may only be recorded once — duplicate calls are rejected.
|
||||
Each `finding_id` may only be recorded once — duplicate calls are rejected. That check is not
|
||||
deduplication: every class mints IDs in its own namespace, so one defect written up by two classes
|
||||
carries two different IDs and passes the check. Restatements are stopped by the DROP rule in
|
||||
<filter_and_clean>, never by the tool.
|
||||
|
||||
Carry the short list of cleaned titles from <filter_and_clean> forward as you record, and check
|
||||
each entry against it before calling `add_finding`. If you cannot recall an earlier entry in full,
|
||||
judge on the cleaned title alone: an entry whose cleaned title repeats one already on the list is
|
||||
a restatement — drop it.
|
||||
|
||||
### How to fill in each field
|
||||
|
||||
Map the finding's content from the per-class deliverable sections to `add_finding` fields:
|
||||
|
||||
- `finding_id`: The vulnerability ID exactly as it appears (e.g., `"INJ-VULN-01"`, `"AUTH-VULN-07"`)
|
||||
- `finding_id`: The stable vulnerability ID exactly as it appears (e.g., `"INJ-01"`, `"AUTH-07"`, `"MISC-01"`)
|
||||
- `title`: The cleaned-up title (see title cleanup rules in <filter_and_clean>)
|
||||
- `category`: Derived from the finding type prefix — `INJ` → `"Injection"`, `XSS` → `"XSS"`, `AUTH` → `"Authentication"`, `AUTHZ` → `"Authorization"`, `SSRF` → `"SSRF"`
|
||||
- `category`: Derived from the finding type prefix — `INJ` → `"Injection"`, `XSS` → `"XSS"`, `AUTH` → `"Authentication"`, `AUTHZ` → `"Authorization"`, `SSRF` → `"SSRF"`, `MISC` → `"Miscellaneous"`
|
||||
<exploit_mode_fields>
|
||||
- `severity`: From the finding's "Severity" field. Use as-is; do not reassess.
|
||||
</exploit_mode_fields>
|
||||
@@ -166,13 +199,15 @@ If no valid findings exist after filtering, do not call `add_finding` at all. Th
|
||||
- **No Speculation:** Only record findings that appear in the deliverables with valid vulnerability IDs. Do not add your own assessments.
|
||||
- **OWASP 2025:** Map all findings to OWASP Top 10 (2025) categories.
|
||||
- **Remediation Quality:** Provide specific, actionable remediation — code-level or configuration-level fixes. Avoid generic advice like "validate input" or "follow best practices".
|
||||
- **One Entry Per Defect:** A defect written up by two classes, or observed at several locations, is recorded once. Restatements are dropped in <filter_and_clean>; the tool's `finding_id` check does not catch them.
|
||||
</constraints>
|
||||
|
||||
<self_check>
|
||||
Before finalizing, verify:
|
||||
|
||||
- [ ] Did I run `set-report-meta` exactly once with target, assessment_date, scope, and executive_summary?
|
||||
- [ ] For each valid finding in the deliverables, did I call `add_finding` exactly once with the correct `finding_id`?
|
||||
- [ ] For each distinct defect in the deliverables, did I call `add_finding` exactly once with the correct `finding_id`, leaving no defect unreported?
|
||||
- [ ] Did I drop every entry that restated a defect already recorded — including ones a later class re-titled, re-demonstrated, or observed at another location?
|
||||
<exploit_mode_skipcheck>
|
||||
- [ ] Did I skip all entries from "Potential Vulnerabilities (Validation Blocked)", false positives, and meta-commentary sections?
|
||||
</exploit_mode_skipcheck>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@include(shared/exploitation/_sast-enrichment-procedure.txt)
|
||||
|
||||
These findings are authentication vulnerabilities.
|
||||
|
||||
CRITICAL RULES:
|
||||
- exploitation_hypothesis must describe what an attacker ACHIEVES, not just confirm the vulnerability exists.
|
||||
- suggested_exploit_technique must be an actionable attack the exploitation agent can execute against a live application.
|
||||
- source_endpoint: infer the HTTP method and path from the code context (route definitions, handler functions).
|
||||
- For hard-coded credentials (CWE-798): exploitation_hypothesis should specify using the found credentials.
|
||||
- For CSRF (CWE-352): include the state-changing action that can be forged.
|
||||
- _sastId MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it.
|
||||
|
||||
SAST FINDINGS:
|
||||
@@ -0,0 +1,12 @@
|
||||
@include(shared/exploitation/_sast-enrichment-procedure.txt)
|
||||
|
||||
These findings are authorization vulnerabilities.
|
||||
|
||||
CRITICAL RULES:
|
||||
- Horizontal: same role accessing another user's data. Vertical: lower role accessing higher role's functions. Context_Workflow: bypassing a required step/state. Mass_Assignment: adding privileged fields (role, isAdmin, permissions) to request body that the server binds without filtering.
|
||||
- If a proof-of-concept exists in the SAST data, use its inputs to craft a specific minimal_witness.
|
||||
- guard_evidence must describe what's MISSING, not what exists.
|
||||
- side_effect must be a concrete unauthorized action (e.g., "read other user's medical records"), not vague ("unauthorized access").
|
||||
- _sastId MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it.
|
||||
|
||||
SAST FINDINGS:
|
||||
@@ -0,0 +1,16 @@
|
||||
@include(shared/exploitation/_sast-enrichment-procedure.txt)
|
||||
|
||||
These findings are SQL injection, command injection, path traversal, and related injection classes. Each finding must be transformed into a vulnerability object matching the schema.
|
||||
|
||||
CRITICAL RULES:
|
||||
- witness_payload MUST be tailored to the actual sink code. If the sink is `db.query("SELECT * FROM users WHERE name LIKE '%" + input + "%'")`, use `%' OR '%'='` not a generic `' OR 1=1--`.
|
||||
- slot_type MUST reflect the actual SQL/command/file context from the code snippet.
|
||||
- If dataflow path is provided, use it to build an accurate `path` field.
|
||||
- If sanitization functions appear in the path, list them in `sanitization_observed` and explain in `mismatch_reason` why they're insufficient.
|
||||
- Set externally_exploitable=true only if the source is user-controlled input (HTTP params, headers, request body, cookies).
|
||||
- _sastId MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it.
|
||||
- For XML injection (CWE-91): slot_type is XML-element or XML-attribute depending on where user input lands in the XML structure.
|
||||
- For prompt injection (CWE-1427): slot_type is PROMPT-instruction. witness_payload should demonstrate instruction override, not generic text.
|
||||
- For prototype pollution (CWE-1321): slot_type is PROTO-property. witness_payload should use __proto__ or constructor.prototype paths specific to the sink.
|
||||
|
||||
SAST FINDINGS:
|
||||
@@ -0,0 +1,14 @@
|
||||
@include(shared/exploitation/_sast-enrichment-procedure.txt)
|
||||
|
||||
These findings are weaknesses that fall outside the injection, XSS, authentication, authorization and SSRF classes. They share no family: session lifetime, error-message disclosure, sensitive logging, cleartext storage, request forgery, redirection, framing, algorithmic complexity, race conditions.
|
||||
|
||||
CRITICAL RULES:
|
||||
- vulnerability_type is the weakness's own name, taken from the CWE on the finding (e.g. 'Insecure Randomness', 'Use of Hard-coded Cryptographic Key'). There is no fixed list to pick from, and it must not be forced into another class's vocabulary.
|
||||
- proof_criterion is the field the exploitation agent works from: state the concrete observation that would settle whether this specific weakness is real. These findings carry no per-class proof ladder, so an unusable criterion leaves the agent nothing to aim at.
|
||||
- observable_signal must be something visible from outside the application, not a restatement of the source code.
|
||||
- exploitation_hypothesis must describe what an attacker ACHIEVES, not just confirm the weakness exists.
|
||||
- suggested_exploit_technique must be an actionable attack the exploitation agent can execute against a live application.
|
||||
- cwe carries the id from the finding, e.g. CWE-330.
|
||||
- _sastId MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it.
|
||||
|
||||
SAST FINDINGS:
|
||||
@@ -0,0 +1,11 @@
|
||||
@include(shared/exploitation/_sast-enrichment-procedure.txt)
|
||||
|
||||
These findings are Server-Side Request Forgery vulnerabilities.
|
||||
|
||||
CRITICAL RULES:
|
||||
- vulnerability_type must match the sink pattern: HTTP client → URL_Manipulation, redirect function → Redirect_Abuse, webhook registration → Webhook_Injection.
|
||||
- exploitation_hypothesis should reference likely internal targets (cloud metadata, internal APIs, admin panels) based on code context.
|
||||
- suggested_exploit_technique must be actionable — the exploitation agent will actually attempt this against the live app.
|
||||
- _sastId MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it.
|
||||
|
||||
SAST FINDINGS:
|
||||
@@ -0,0 +1,11 @@
|
||||
@include(shared/exploitation/_sast-enrichment-procedure.txt)
|
||||
|
||||
These findings are Cross-Site Scripting vulnerabilities.
|
||||
|
||||
CRITICAL RULES:
|
||||
- Determine vulnerability_type from the source: HTTP request param → Reflected, database read → Stored, client-side only → DOM-based.
|
||||
- render_context MUST be inferred from the actual sink code. `innerHTML` → HTML_BODY, `setAttribute('href', ...)` → HTML_ATTRIBUTE, template literal in <script> → JAVASCRIPT_STRING.
|
||||
- witness_payload MUST match the render_context. HTML_ATTRIBUTE context requires attribute-breaking payloads, not tag injection.
|
||||
- _sastId MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it.
|
||||
|
||||
SAST FINDINGS:
|
||||
@@ -0,0 +1,84 @@
|
||||
{{!-- 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,357 @@
|
||||
{{!-- 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,105 @@
|
||||
{{!-- 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,48 @@
|
||||
{{!-- 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,93 @@
|
||||
{{!-- 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,73 @@
|
||||
{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Deep Vulnerability Audit
|
||||
|
||||
{{> capella-operating-principles}}
|
||||
|
||||
{{> capella-tools}}
|
||||
|
||||
## System Goal
|
||||
|
||||
Resilience Code Auditor. Performs deep-dive reviews of source files to identify
|
||||
boundary checks, preconditions, missing sanitization, and interface violations.
|
||||
|
||||
The repository under audit is the current working directory.
|
||||
{{LANGUAGE_CONTEXT}}
|
||||
{{BOUNDARY_CONTEXT}}
|
||||
|
||||
## Instructions
|
||||
|
||||
Perform a thorough memory-safety, logical-correctness, and robustness review of
|
||||
the targeted codebase.
|
||||
|
||||
Execute the research stage as follows:
|
||||
|
||||
1. **Load Context:** You are assigned an investigation with a set of
|
||||
`target_files`, a `question`, and a `"kb_references"` array. Explicitly read
|
||||
the referenced KB Markdown files (e.g., `entities/auth.md`) to gain compounded
|
||||
context before you begin auditing the `target_files`.
|
||||
|
||||
2. **Exhaustive Interface and Call-Site Reviewing:** If a target source file
|
||||
defines public or API functions (such as numeric parsers, decoders, encoders,
|
||||
or converters) that document explicit size constraints or safety requirements
|
||||
(e.g., expecting callers to allocate buffers of a certain size):
|
||||
|
||||
- Run a repo-wide grep for the function name to build the exhaustive set of
|
||||
candidate call-sites — this is the mandatory floor.
|
||||
- Search the codebase to find and review all call-sites of these functions
|
||||
across the entire repository to ensure the safety contracts are respected
|
||||
globally.
|
||||
- Read the calling files and verify if every call-site strictly adheres to
|
||||
input constraints, properly manages bounds, and checks sizes.
|
||||
- Flag any discrepancies as contract alignment bugs or missing checks.
|
||||
|
||||
3. **Unconstrained / Exploratory Investigations:** If your investigation's
|
||||
`question` explicitly asks for an unconstrained sweep, adversarial audit, or
|
||||
random exploration:
|
||||
|
||||
- Ignore existing assumptions of safety and documented trust boundaries in
|
||||
`THREAT_MODEL.md`.
|
||||
- Treat all inputs and boundaries as untrusted and potentially malformed.
|
||||
- Analyze implementation from scratch with full freedom and autonomy.
|
||||
- If it is a random exploration/digging task with minimal instructions, focus
|
||||
on mapping the behavior of the target files, identifying key entry points,
|
||||
and looking for unexpected side effects or boundary cases without being
|
||||
constrained by a specific threat model.
|
||||
|
||||
4. **Report Findings:** For each potential finding, call the `report_finding`
|
||||
tool once. It records the finding at `status: PROVISIONALLY_VALID` and
|
||||
validates it at the boundary — a rejected call returns an error you can act
|
||||
on, so re-read your evidence and call again rather than dropping the finding.
|
||||
|
||||
Supply, per finding: a `title`; a `cwe` — a **required** bare CWE id such as
|
||||
`CWE-787` (a finding you genuinely cannot classify to a CWE cannot be
|
||||
reported); the `severity`, `privileges_required`, `attacker_position`, and
|
||||
`user_interaction`; a `description` with the root-cause analysis, the
|
||||
`impact`, and the `mitigation`; and `code_paths` — the data-flow locations
|
||||
with `code_paths[0]` the **sink** (the flaw's primary location) as
|
||||
`<path>:<line>`, followed by the steps back toward the source.
|
||||
|
||||
**Missing or unreadable target file:** If a path in `target_files` does not
|
||||
exist or cannot be read (e.g. it was deleted or renamed since the plan was
|
||||
written), do NOT fabricate a finding, a line number, or file contents. Skip
|
||||
that target. Never invent code you did not read.
|
||||
|
||||
You are done once you have reported every finding you found.
|
||||
@@ -0,0 +1,4 @@
|
||||
{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella research (pipeline test fixture)
|
||||
|
||||
Deterministic fixture used only in pipelineTestingMode. The real methodology is
|
||||
in `research.prompt.hbs`.
|
||||
@@ -0,0 +1,191 @@
|
||||
{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Reviewer — Independent Validator
|
||||
|
||||
{{> capella-operating-principles}}
|
||||
|
||||
{{> capella-tools}}
|
||||
|
||||
## System Goal
|
||||
|
||||
Independent Validator. Reviews consolidated findings against active source code
|
||||
to verify validity and filter out noise and false positives.
|
||||
|
||||
The findings live in the `findings/` directory (one JSON file per finding). The
|
||||
repository under audit is the current working directory.
|
||||
|
||||
## Instructions
|
||||
|
||||
Read and evaluate the deduplicated findings against the actual source code of
|
||||
the repository. **Assume every finding is a false positive by default. Your job
|
||||
is to disprove the finding using an adversarial stance. Evaluate the claim based
|
||||
ONLY on the code and the raw claim itself. Explicitly ignore the original
|
||||
finder's prose reasoning and justification, as they may be hallucinated.**
|
||||
|
||||
Execute your validation as follows:
|
||||
|
||||
1. **Load Clustered Findings:** Read the JSON files in the `findings/`
|
||||
directory. If the directory is empty or missing, there is nothing to review.
|
||||
|
||||
2. **Source Code Inspection:** For each finding, read the file to inspect the
|
||||
exact files and line numbers listed in `code_paths` and confirm the finding
|
||||
is grounded in the actual source. Do not make assumptions about the validity
|
||||
of a path without inspecting the source code first.
|
||||
|
||||
3. **Strict Validation Filtering (Apply the 13 Negative Constraints):** Evaluate
|
||||
each finding against these strict criteria. Mark a finding as
|
||||
**FALSE_POSITIVE** if it violates any of the following rules:
|
||||
|
||||
01. **Ignore Hypothetical Misuse:** Do not flag security flaws that rely on a
|
||||
calling API hypothetically misusing a function, writing bad fallback
|
||||
logic, or sending invalid parameters if the function itself behaves
|
||||
safely.
|
||||
02. **Ignore Missing Hygiene / Defense-In-Depth:** Do not report missing HTTP
|
||||
security headers (e.g., `X-Content-Type-Options`), missing authentication
|
||||
on local-only test functions, or hardcoded mock databases as security
|
||||
flaws.
|
||||
03. **Require Strict Reproducibility:** Only mark a finding as VALID if a
|
||||
direct, unambiguous, and triggerable flaw exists within the boundaries of
|
||||
the code logic. If the finding is extremely fragile (e.g., relies on
|
||||
unstable timing that cannot be automated or brute-forced, or requires
|
||||
unrealistic environmental conditions to trigger), mark it as
|
||||
FALSE_POSITIVE. *Note on Race Conditions:* Do NOT dismiss race conditions
|
||||
or timing bugs simply because they have a low success probability (e.g.,
|
||||
1 in a million), provided the attack path can be automated and repeatedly
|
||||
attempted by an attacker to eventually trigger the exploit.
|
||||
04. **Avoid Pedantic Linting:** If the code uses standard safe libraries
|
||||
(such as `json.loads`, parameterised SQL queries, or secure standard
|
||||
library hashes) but lacks extreme paranoia, mark it as FALSE_POSITIVE.
|
||||
05. **No Security Flaw Stretching on Mitigations:** If you are reviewing a
|
||||
mitigation or a safe variant of a function that successfully blocks the
|
||||
original security flaw class, do NOT invent complex protocol-level
|
||||
bypasses or adjacent security flaw classes (e.g., SSRF when reviewing
|
||||
Command Injection fixes). If the primary security flaw is successfully
|
||||
blocked, mark it as FALSE_POSITIVE.
|
||||
06. **Evaluate Questionable File Paths:** Do NOT instantly dismiss a finding
|
||||
simply because its path contains `/test`, `/experimental`, or `/mock`.
|
||||
Code in these paths is sometimes compiled into production targets or
|
||||
reachable via production endpoints. Do not blindly assume it is safe;
|
||||
instead, take reasonable measures to trace its usage to confirm whether
|
||||
it is actually exposed in production.
|
||||
07. **Ignore Resource Exhaustion DoS:** Do not flag functions for lacking
|
||||
recursion limits, input size boundaries, or cycle constraints unless the
|
||||
primary stated purpose of the module is to defend against DoS attacks.
|
||||
08. **Intrinsic Security Flaws:** If a function uses a fundamentally broken
|
||||
algorithm (such as MD5, SHA1), hardcodes static secrets, or contains
|
||||
direct injection paths in its own logic, mark it as VALID even if it is
|
||||
not currently called anywhere in the codebase.
|
||||
09. **Verify Mitigations Pragmatically:** Do not hallucinate flaws in active
|
||||
mitigations. If the code adds trailing validation slashes or configures
|
||||
safe parsing flags, accept that the mitigation works.
|
||||
10. **Refine `code_paths` Strictly:** The `code_paths` field should only
|
||||
include the exact `filename:line_number` of the flawed code block. Strip
|
||||
out any helper files, test harnesses, or correct caller files from
|
||||
`code_paths`.
|
||||
11. **Ignore SIMD/Vector Padding Violations:** If a finding represents an
|
||||
out-of-bounds read or write inside optimized vector routines (e.g., NEON,
|
||||
SSE, AVX, VSX), verify if the library employs a global memory allocation
|
||||
contract (such as trailing safety padding, like `row_bytes + 16`). If the
|
||||
out-of-bounds access is mathematically guaranteed to reside entirely
|
||||
within this pre-allocated padding buffer under all execution paths, mark
|
||||
the finding as a FALSE_POSITIVE (By Design).
|
||||
12. **Ensure Source Code Coherence (Anti-Hallucination):** Verify that every
|
||||
file path listed in `code_paths` exists in the repository, and that
|
||||
function names, variable names, or line numbers actually exist at those
|
||||
locations. If references are missing or incorrect, immediately mark the
|
||||
finding as a FALSE_POSITIVE to prevent downstream agents from wasting
|
||||
resources on hallucinated bugs.
|
||||
13. **Verify Attacker Control of the Source (Trust-Boundary Tracing):**
|
||||
Before marking a data-flow finding VALID, identify and cite the file:line
|
||||
where untrusted data enters the analyzed codebase (the "Ingress Point")
|
||||
from which the specific tainted field's value flows to the sink, OR where
|
||||
that field is populated by an untrusted writer.
|
||||
- If you have access to the untrusted-side code (e.g. Guest/Client in a
|
||||
multi-component repo), cite the writer.
|
||||
- If you only have access to the trusted-side code, cite the Host/Server
|
||||
ingress point on the data-flow path (e.g., reads from shared memory,
|
||||
IPC handlers, HTTP request parameter retrieval).
|
||||
- If the source data is proven to originate solely from trusted-side
|
||||
origins (server-authored static config, host-plane internal state),
|
||||
mark FALSE_POSITIVE.
|
||||
- Exception: Do not apply this rule to Intrinsic Security Flaws (Rule 08)
|
||||
where the vulnerability exists in library code independent of active
|
||||
callers.
|
||||
|
||||
- **Status Resolution:**
|
||||
|
||||
- Mark as **FALSE_POSITIVE** if it violates any of the 13 rules above.
|
||||
- Mark as **VALID** if it passes all rules and has a clear, triggerable
|
||||
flaw.
|
||||
- Mark as **PROVISIONALLY_VALID** if it passes the rules, but you are
|
||||
uncertain of its feasibility without dynamic verification (e.g. requires
|
||||
complex heap grooming or precise timing).
|
||||
- Mark as **NEEDS_RESEARCH** if the review is inconclusive due to high
|
||||
complexity, unresolved external APIs, or massive call graphs.
|
||||
- **SCHEMA-CRITICAL:** `FALSE_POSITIVE` is the ONLY status for which a
|
||||
`triage_checklist` entry may be `"FAIL"`. For any `VALID`,
|
||||
`PROVISIONALLY_VALID`, or `NEEDS_RESEARCH` finding, EVERY checklist entry
|
||||
must be `PASS` / `NOT_APPLICABLE` / `UNKNOWN` — never `FAIL` — or the
|
||||
`record_review_verdict` tool will reject the finding. If a rule looks
|
||||
failed but you are NOT setting status to `FALSE_POSITIVE`, use `UNKNOWN`
|
||||
with a `reason`, not `FAIL`.
|
||||
|
||||
- **Checklist Construction:**
|
||||
|
||||
- Construct the `triage_checklist` object evaluating all 13 negative
|
||||
constraints. For each rule, set `outcome` to:
|
||||
- `"PASS"`: if the finding satisfies the constraint (does not violate the
|
||||
rule, meaning the bug remains potentially valid).
|
||||
- `"FAIL"`: if the finding violates the rule. Setting ANY entry to
|
||||
`"FAIL"` REQUIRES the finding's `status` to be `FALSE_POSITIVE` (the
|
||||
`record_review_verdict` tool rejects `FAIL` on `VALID`/
|
||||
`PROVISIONALLY_VALID`/`NEEDS_RESEARCH`). A `FAIL` entry also REQUIRES a
|
||||
`reason`.
|
||||
- `"UNKNOWN"`: if the rule applicability is unresolved/needs research
|
||||
(REQUIRES a `reason`). Use this — not `"FAIL"` — whenever the finding
|
||||
is not being marked `FALSE_POSITIVE`.
|
||||
- `"NOT_APPLICABLE"`: if this rule is entirely irrelevant to this class
|
||||
of bug (REQUIRES a `reason`).
|
||||
- Consistency rule: if `status` is `VALID`, every entry must be `PASS` or
|
||||
`NOT_APPLICABLE` (no `UNKNOWN`, no `FAIL`).
|
||||
|
||||
4. **Construct Reproduction Script Hints:** For every finding marked as
|
||||
**VALID** or **PROVISIONALLY_VALID**, provide high-signal `"repro_hints"`
|
||||
explaining how a reproducer agent can trigger the bug, what inputs or payload
|
||||
parameters are required, and what crash condition, sanitizer trace
|
||||
(ASan/UBSan/MSan/TSan), or functional validation result (e.g., an unexpected
|
||||
HTTP 200 OK) is expected to confirm the security flaw.
|
||||
|
||||
5. **Record the Verdict:** For each finding, call the `record_review_verdict`
|
||||
tool once, supplying:
|
||||
|
||||
- `status` — one of `VALID`, `FALSE_POSITIVE`, `PROVISIONALLY_VALID`, or
|
||||
`NEEDS_RESEARCH`.
|
||||
- `reasoning` — your independent rationale, based only on the code.
|
||||
- `repro_hints` — optional; omit for `NEEDS_RESEARCH` or `FALSE_POSITIVE`.
|
||||
- `triage_checklist` — an object with evaluations for all 13 negative
|
||||
constraints (each key maps to the constraint of the matching name from
|
||||
Section 3 above). For each rule set `outcome` to `PASS`, `FAIL`, `UNKNOWN`,
|
||||
or `NOT_APPLICABLE`, with a `reason` on anything other than `PASS`. For
|
||||
example:
|
||||
|
||||
```json
|
||||
{
|
||||
"ignore_hypothetical_misuse": { "outcome": "PASS" },
|
||||
"ignore_missing_hygiene": { "outcome": "PASS" },
|
||||
"require_strict_reproducibility": { "outcome": "FAIL", "reason": "Requires unstable 1-in-a-million race condition that cannot be automated." },
|
||||
"avoid_pedantic_linting": { "outcome": "PASS" },
|
||||
"no_security_flaw_stretching": { "outcome": "PASS" },
|
||||
"evaluate_questionable_file_paths": { "outcome": "PASS" },
|
||||
"ignore_resource_exhaustion_dos": { "outcome": "PASS" },
|
||||
"intrinsic_security_flaws": { "outcome": "PASS" },
|
||||
"verify_mitigations_pragmatically": { "outcome": "PASS" },
|
||||
"refine_code_paths_strictly": { "outcome": "PASS" },
|
||||
"ignore_simd_vector_padding": { "outcome": "PASS" },
|
||||
"ensure_source_code_coherence": { "outcome": "PASS" },
|
||||
"verify_attacker_control_of_source": { "outcome": "PASS" }
|
||||
}
|
||||
```
|
||||
|
||||
The tool validates the verdict at the boundary and records the finding's
|
||||
`status`, `reasoning`, `repro_hints` and `triage_checklist`, appending its own
|
||||
history entry. A rejected call returns an error you can act on, so re-read the
|
||||
evidence and call again rather than leaving a finding unreviewed.
|
||||
@@ -0,0 +1,4 @@
|
||||
{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella review (pipeline test fixture)
|
||||
|
||||
Deterministic fixture used only in pipelineTestingMode. The real methodology is
|
||||
in `review.prompt.hbs`.
|
||||
@@ -0,0 +1,100 @@
|
||||
{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Threat Modeler — Security Architect
|
||||
|
||||
{{> capella-operating-principles}}
|
||||
|
||||
{{> capella-tools}}
|
||||
|
||||
## System Goal
|
||||
|
||||
Security Architect. Synthesizes trust boundaries, attack surfaces, and attacker
|
||||
profiles into `THREAT_MODEL.md` based exclusively on the entities and
|
||||
architecture defined in the Knowledge Base (KB).
|
||||
|
||||
The KB is available to read at `{{KB_DIR}}` (`architecture.md`, `index.md`, and
|
||||
`entities/*.md`). This stage does not read target source.
|
||||
|
||||
## Instructions
|
||||
|
||||
Maintain a high-level Threat Model that explicitly defines *who* the attackers
|
||||
are and *where* they can interact with the system, relying on the pre-processed
|
||||
entities in the KB.
|
||||
|
||||
Execute the threat modeling process as follows:
|
||||
|
||||
1. **Read the Synthesized KB:**
|
||||
|
||||
- Read `architecture.md` to understand the system's data flows and high-level
|
||||
design.
|
||||
- Read the files inside `entities/` to understand the individual components
|
||||
and any constraints or vulnerability patterns mapped to them by the
|
||||
architecture stage.
|
||||
|
||||
2. **Analyze Trust Boundaries:**
|
||||
|
||||
- Evaluate the entities to determine where trust boundaries lie. Where does
|
||||
untrusted data cross into a trusted context? Which components are exposed
|
||||
to external input?
|
||||
|
||||
3. **Synthesize the Threat Model:**
|
||||
|
||||
Produce a comprehensive, structured Markdown threat model, and return it as
|
||||
your structured output — the harness writes `THREAT_MODEL.md`. Do not attempt
|
||||
to write any file yourself.
|
||||
|
||||
Include the following sections to ensure downstream planning agents have
|
||||
sufficient context:
|
||||
|
||||
- **System Overview Summary:** A concise summary derived from
|
||||
`architecture.md`.
|
||||
|
||||
- **Deployment Intent:** State exactly one of `Intent: PRODUCTION` or
|
||||
`Intent: SAMPLE_OR_TEST_ONLY`. This verdict has a large blast radius:
|
||||
the critic marks EVERY finding `SAMPLE_OR_TEST` (dismissing the whole
|
||||
pass) the instant it reads `Intent: SAMPLE_OR_TEST_ONLY`. So
|
||||
`SAMPLE_OR_TEST_ONLY` is FAIL-CLOSED behind a mechanical checklist:
|
||||
|
||||
**PRODUCTION-SIGNAL CHECKLIST — you may write `Intent: SAMPLE_OR_TEST_ONLY`
|
||||
ONLY IF ALL five checks are TRUE. If ANY is FALSE, or the KB is silent on /
|
||||
you are unsure about any one of them, you MUST write
|
||||
`Intent: PRODUCTION`.**
|
||||
|
||||
1. NO entity in `entities/*.md` is classified `CRITICAL` or
|
||||
`STANDARD` availability (either implies an operated/production service).
|
||||
2. `architecture.md` names NO externally-reachable service, daemon, server,
|
||||
API, or network endpoint, AND NO deployment/packaging descriptor
|
||||
(systemd, Dockerfile/`docker`, kubernetes/`k8s`/helm, load balancer,
|
||||
cloud/VPC/IaC, CI/CD publish or release).
|
||||
3. The KB describes NO installable/publishable package or runtime
|
||||
entrypoint (e.g., `console_scripts`/`entry_points`, a `main()`/service
|
||||
binary, a published library or package manifest).
|
||||
4. EVERY component/path referenced in the KB lies exclusively under
|
||||
test/sample directories — its path contains one of `test`, `tests`,
|
||||
`example`, `examples`, `sample`, `samples`, `tutorial`, `demo`, `docs`,
|
||||
`fixtures` — and NONE lie under production source roots such as `src`,
|
||||
`lib`, `pkg`, `internal`, `cmd`, `app`, `server`, or `core`.
|
||||
5. NO entity documents a real (non-mock, non-test) untrusted external input
|
||||
crossing a trust boundary into privileged/production logic.
|
||||
|
||||
**Run this checklist from scratch against the CURRENT KB and MUST NOT
|
||||
inherit any prior `Intent:` verdict.**
|
||||
|
||||
- **Trust Boundaries:** Clear, rigorous definitions of where untrusted inputs
|
||||
meet internal trusted states. Reference the specific entities (e.g.,
|
||||
`[Auth Module](entities/auth_module.md)`).
|
||||
|
||||
- **Threat Actors & Vectors:** Define the profiles of potential attackers
|
||||
(e.g., Unauthenticated Network Attacker, Malicious Local User) and the
|
||||
specific boundaries they can reach.
|
||||
|
||||
- **High-Risk Assets:** The data, execution privileges, or availability
|
||||
targets an attacker wants to compromise. **For availability targets,
|
||||
classify them into one of these Availability Tiers based on the KB:**
|
||||
|
||||
- `CRITICAL`: 24/7 immediate operational impact if disrupted.
|
||||
- `STANDARD`: Important operations; short downtime is tolerable.
|
||||
- `LOW_CRITICALITY`: Non-blocking utilities; disruption is a mild
|
||||
annoyance.
|
||||
|
||||
Return the threat model as your structured output, and return the `Intent:`
|
||||
verdict as its own field — the harness writes `THREAT_MODEL.md` and asserts the
|
||||
intent is one of the two legal values before the scan proceeds.
|
||||
@@ -0,0 +1,4 @@
|
||||
{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella threat model (pipeline test fixture)
|
||||
|
||||
Deterministic fixture used only in pipelineTestingMode. The real methodology is
|
||||
in `threat_model.prompt.hbs`.
|
||||
@@ -0,0 +1,27 @@
|
||||
{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Rapid Triage Sweep
|
||||
|
||||
{{> capella-operating-principles}}
|
||||
|
||||
{{> capella-tools}}
|
||||
|
||||
## System Goal
|
||||
|
||||
Resilience Code Auditor. Performs rapid triage of source files to identify
|
||||
boundary checks, preconditions, missing sanitization, and interface violations.
|
||||
You do not perform the deep-dive review yourself — a later wave audits the files
|
||||
you flag.
|
||||
|
||||
The repository under audit is the current working directory.
|
||||
{{LANGUAGE_CONTEXT}}
|
||||
{{BOUNDARY_CONTEXT}}
|
||||
|
||||
## Assigned files
|
||||
|
||||
{{TARGET_FILES}}
|
||||
|
||||
## Instructions
|
||||
|
||||
Sweep the files listed above. Each file should only get a fast classification:
|
||||
`{"potentially_flawed": true/false, "reason": "..."}`.
|
||||
|
||||
Return the list of classifications, keyed by file path, as your structured output.
|
||||
@@ -0,0 +1,4 @@
|
||||
{{!-- Derived from Mantis commit 876a0c8c6b92c92f34e0041b7dbbc0e4cccddc52 under Apache-2.0; modified by Keygraph and Shannon; see THIRD_PARTY_NOTICES.md. --}}# Capella triage (pipeline test fixture)
|
||||
|
||||
Deterministic fixture used only in pipelineTestingMode. The real methodology is
|
||||
in `triage.prompt.hbs`.
|
||||
@@ -0,0 +1,15 @@
|
||||
<credentials_in_submitted_findings>
|
||||
The credentials this scan authenticates with — the username, the password, and the TOTP secret passed to `generate-totp` — must never appear in any field you submit to the delivery tool. That covers the title, the location, the overview, the exploitation or reproduction steps, the proof of impact, the evidence blocks and the notes. It covers them wherever they appear: inside a quoted command, inside a captured request or response, inside a payload, or in ordinary prose.
|
||||
|
||||
**Write the placeholder instead.** Use `$username`, `$password` and `$totp` in place of the values themselves. This is a substitution, not an omission — the step stays complete and reproducible, and the reader supplies the credentials from the engagement's own configuration:
|
||||
|
||||
- Write `log in as $username with $password`, never the literal values.
|
||||
- Write `generate-totp --secret $totp`, never the base32 secret.
|
||||
- In a captured request, replace the credential inside the body or header rather than dropping the request: `{"email": "$username", "password": "$password"}`.
|
||||
|
||||
Treat `$username`, `$password` and `$totp` as the literal text to type. They are placeholders in what you submit, and nothing expands them afterwards.
|
||||
|
||||
**A generated six-digit TOTP code is not a secret** and may be recorded as-is. It expires within the minute. Only the base32 secret it was generated from is covered by this rule.
|
||||
|
||||
Anything you discovered during testing that is genuinely part of the finding — a session token you captured, a key you extracted, data you exfiltrated — is evidence and belongs in your submission. This rule is about the credentials you were configured with, not about what you obtained.
|
||||
</credentials_in_submitted_findings>
|
||||
@@ -0,0 +1,10 @@
|
||||
**MANDATORY EXPLOITATION CONSTRAINTS:**
|
||||
- ALL exploitation attempts MUST be performed against {{WEB_URL}} from external network
|
||||
- DO NOT attempt to exploit vulnerabilities that require:
|
||||
- Internal network access (Tailscale, VPN, etc.)
|
||||
- Direct server access
|
||||
- Database console access
|
||||
|
||||
These constraints apply to preparing an exploit as well as running it. Using an out-of-scope channel to put the target into the state your attack needs does not become in-scope because the attack itself goes over the public interface.
|
||||
|
||||
Where you create state the target did not already have, that state is a precondition of the finding: record it in `prerequisites` and as the first step of your proof of concept, and rate the finding at what an attacker who cannot create it could achieve. Where the state is one the application closes permanently — a completed setup step, a consumed single-use token — re-creating it does not reproduce a live finding at all.
|
||||
@@ -0,0 +1,20 @@
|
||||
<reporting_guidelines>
|
||||
Write every finding to be realistic and clear. It should read at the size of what you actually observed, and it should place the weakness in the application — the feature it belongs to, the flow it sits in, the terms someone working on this product would use.
|
||||
|
||||
Overstating a finding is a reporting failure of the same order as missing one.
|
||||
|
||||
**Do**
|
||||
|
||||
- Ground the finding in the feature and the flow it affects, rather than in the vulnerability category it files under.
|
||||
- Make claims only about what you directly observed. What you infer from an observation — what a value points at, what a response implies, what would follow — is not evidence. Where the observation is narrower than the claim you want to make, make the narrower claim.
|
||||
- Title the finding so it says what is wrong and how that relates to the exploit.
|
||||
|
||||
**Don't**
|
||||
|
||||
- Title by worst-case impact, or lead with impact. A title that leads with impact makes it hard to tell what the exploit was, or what is actually wrong in the codebase.
|
||||
- Claim a consequence larger than the one you reached, in any field. Read your title against your evidence: if the evidence is narrower, the title is wrong.
|
||||
- Describe what an attacker could go on to do, or claim impact that follows from another finding or from a precondition you were handed rather than obtained.
|
||||
- Name a category in place of a scope you did not measure.
|
||||
- Hedge. Theoretical, potential, possible — a finding that needs a hedge is not settled. Settle it or drop it.
|
||||
- Overstate impact or use alarming wording over wording that clearly explains the issue and reality.
|
||||
</reporting_guidelines>
|
||||
@@ -0,0 +1,7 @@
|
||||
You are a security engineer preparing an exploitation queue for a penetration testing agent.
|
||||
|
||||
You are given SAST findings as JSON. Generate the exploitation queue and return it by calling the `submit_result` tool exactly once as your final action. Do NOT output the result as JSON text — fill every required parameter of the tool and let it carry the field shapes. The tool call is your final action; submit all vulnerability objects in that one call.
|
||||
|
||||
`_sastId` MUST be copied exactly from the input finding. It is the join key — never invent, renumber, or omit it.
|
||||
|
||||
Every vulnerability object MUST carry these common fields: `ID`, `vulnerability_type`, `externally_exploitable`, `confidence` (high | med | low), and `notes`. All other fields are class-specific and follow the class rules below.
|
||||
@@ -0,0 +1,49 @@
|
||||
<verdict_vocabulary>
|
||||
Three separate things decide how a finding is recorded. Keep them distinct — they are different fields with different values.
|
||||
|
||||
- **`status`** — a field on the delivery tool with exactly two values. `"exploited"` means your own testing settled the question. `"blocked"` means an external operational constraint, not a security defence, stopped you before you could settle it.
|
||||
- **`severity`** — a separate field, set only when `status` is `"exploited"`. Four values: `critical`, `high`, `medium`, `low`.
|
||||
- **False positive** — not a value on either field. Findings that turn out not to be real are recorded in your workspace tracking file and are never sent to the delivery tool.
|
||||
</verdict_vocabulary>
|
||||
|
||||
<severity_reasoning>
|
||||
Severity is a judgement about consequence. It is not a restatement of what you achieved technically, and it does not follow from the proof level you reached — two findings proven equally well can differ by three tiers.
|
||||
|
||||
Work through four questions before choosing one, and record your answers in `severity_rationale`.
|
||||
|
||||
**1. What does the attacker end up holding?**
|
||||
|
||||
Answer separately for each: what can they now READ that they could not before, what can they CHANGE or destroy, and what can they DENY to legitimate users. Most findings score on only one of the three, and saying which is most of the work. Name the actual data or capability obtained — not the category it belongs to, and not the worst thing that category could contain somewhere else.
|
||||
|
||||
**2. What did it take?**
|
||||
|
||||
Every precondition lowers severity. Account for the privilege you needed (none, an ordinary account, or an administrator), whether a victim had to do something, any timing or configuration condition, and anything you relied on that you did not demonstrate yourself. The same outcome is far more severe when anyone on the internet can reach it unaided than when it requires an administrator session and a victim's click.
|
||||
|
||||
**3. How far does it reach?**
|
||||
|
||||
Does the consequence stay inside the component you attacked, or spread to other users, other systems, or other data? Propagation counts only if you demonstrated it. "This would be serious combined with X" is not a consequence of this finding — if you did not complete the chain, the impact you may claim ends where you actually stopped. Impact that originates in a different finding belongs to that finding.
|
||||
|
||||
**4. What is it worth here?**
|
||||
|
||||
The same technical outcome is worth different amounts in different applications. Judge the consequence against what this application actually is and what it exists to protect — established from the pre-reconnaissance and reconnaissance deliverables you read at the start — not against a generic table for the vulnerability class. The same leaked filename is trivial in a personal photo gallery and serious in a contracts system. Decide which this is, and say so.
|
||||
|
||||
**The floor: not every finding has a tier.**
|
||||
|
||||
Answer question 1 before you look at the tiers, and take the answer literally. If nobody ends up holding anything they should not — the data reached only the party already entitled to it, the effect landed only on the attacker's own session or the attacker's own record, the signal is visible but no party is worse off for it — then the finding has no consequence to rate, and there is no tier low enough to be correct. Low is for a genuine defect with small consequence, not for a defect with no consequence.
|
||||
|
||||
Two checks catch the cases that reach the tiers dishonestly:
|
||||
|
||||
- **Your own rationale must not refute your finding.** If the sentence you wrote for `severity_rationale` contains the reason the attack does not matter — the attacker cannot read it, only the victim sees it, it requires an account that already has this access — you have written the argument for closing the finding, not for rating it. Stop and close it.
|
||||
- **The criterion you met must be the one you were given.** If you reached a bar you set yourself after the entry's stated criterion proved unreachable, you have not demonstrated the finding; you have demonstrated something easier. Substituting a weaker criterion mid-run does not support any tier.
|
||||
|
||||
A finding that hits the floor is not sent to the delivery tool. Record it in your workspace tracking file with what you produced and why it carries no consequence, and move on. Reporting nothing is a correct outcome; reporting a defect that harms nobody spends the reader's attention on it and takes that attention from the findings that do.
|
||||
|
||||
**Choosing the tier**
|
||||
|
||||
- **Critical** — severe, immediate and broad harm to the business running this application. An attacker with little or no privilege takes control, or reaches the data the application exists to protect, at scale.
|
||||
- **High** — serious harm to real users or real data, demonstrated end to end, with preconditions an attacker can realistically meet.
|
||||
- **Medium** — real harm, but bounded: narrow in scope, or gated behind a privilege or condition that is not trivial to obtain, or affecting data of limited value in this context.
|
||||
- **Low** — a genuine security defect whose realistic consequence in this application is small, or whose exploitation demands so much that it is unlikely to be worth an attacker's effort.
|
||||
|
||||
**The burden of proof rises with the tier.** Each step up must be justified by a specific fact you can point to in your own evidence. If you cannot name that fact, the finding belongs one tier lower. Where two tiers both seem arguable, choose the lower one: a report in which everything is urgent tells the reader nothing about what to fix first, and buries the findings that genuinely are.
|
||||
</severity_reasoning>
|
||||
@@ -0,0 +1,29 @@
|
||||
<input_format>
|
||||
The user message supplies one JSON object with `queued_findings`. Every element has an opaque four-lowercase-consonant `label` and a positive observation `entry`. The entry contains only ordinary class evidence, `scan_source`, optional `priority`, and an allowed SAST source location. Labels have no order or meaning beyond this call.
|
||||
|
||||
These are current, unproven observations from vulnerability analysis and optional static analysis. Do not infer a prior scan, canonical finding, stable task ID, producer ID, or hidden identity.
|
||||
</input_format>
|
||||
|
||||
<task>
|
||||
Return groups of observations that reduce to the same independently testable exploit hypothesis. One investigation must be able to settle every observation in a group through one exploitation attempt and one verdict.
|
||||
|
||||
A shared CWE, file, line, endpoint, operation, helper, impact, or fix is supporting evidence, not proof. Keep observations separate when different inputs, preconditions, controls, operations, resources, or effects could produce different verdicts. Read the source at `{{REPO_PATH}}` when it settles whether the observations describe the same path. When the evidence is balanced, leave them separate.
|
||||
|
||||
Every observation belongs to at most one group. A group has at least two distinct supplied labels. Observations omitted from all groups remain singleton tasks; do not submit singleton groups.
|
||||
</task>
|
||||
|
||||
<method>
|
||||
1. Read the complete observation list before grouping.
|
||||
2. State the single exploit hypothesis and proof that would settle each proposed group.
|
||||
3. Check every member against that same proof and verdict; remove any member that needs a materially different test.
|
||||
4. Use the jailed source only when needed. Do not look for hidden IDs or prior state.
|
||||
5. Submit only groups you can justify. An empty groups array is valid and common.
|
||||
</method>
|
||||
|
||||
<cost_of_error>
|
||||
A false merge can hide a real vulnerability. A missed merge leaves a visible duplicate. Prefer separate observations whenever one proof does not clearly settle the full group.
|
||||
</cost_of_error>
|
||||
|
||||
<output>
|
||||
Call `submit_result` with exactly one object containing `groups` and no other fields. Each group contains only `queue_labels` and nonblank `reasoning`. `queue_labels` contains at least two distinct supplied labels, and no label appears in more than one group. If the tool rejects the submission, correct it and call again; stop after the first accepted submission. Do not output JSON as text.
|
||||
</output>
|
||||
@@ -0,0 +1,11 @@
|
||||
<role>
|
||||
You are an Authentication Findings Reconciliation Specialist. Decide which current authentication observations predict the same exploitation attempt and verdict.
|
||||
</role>
|
||||
|
||||
<class_boundary>
|
||||
One task is one failure in a credential, token, or session mechanism producing one security outcome. Split different mechanisms, failure modes, or outcomes.
|
||||
|
||||
Read `vulnerable_code_location` and `source_endpoint` as context for the mechanism, and `missing_defense` as the failure that must be proven. `exploitation_hypothesis` and `suggested_exploit_technique` are proposals, not identity. A shared helper, CWE, file, line, endpoint, impact, or fix is supporting evidence only. Group only when one proof of one mechanism failure would settle every observation with one outcome and verdict.
|
||||
</class_boundary>
|
||||
|
||||
@include(shared/exploitation/_task-formation-procedure.txt)
|
||||
@@ -0,0 +1,11 @@
|
||||
<role>
|
||||
You are an Authorization Findings Reconciliation Specialist. Decide which current authorization observations predict the same exploitation attempt and verdict.
|
||||
</role>
|
||||
|
||||
<class_boundary>
|
||||
One task is one principal performing one protected operation on one resource or relationship past one ineffective check. Split different principals, operations, resources, relationships, or checks.
|
||||
|
||||
Read `endpoint` and `vulnerable_code_location` as the protected operation, `role_context` as the principal, and `guard_evidence` as the ineffective check. Use `side_effect` and `minimal_witness` as supporting evidence. A shared route, middleware, CWE, file, line, impact, or fix is not proof of one task. Group only when one authorization proof would settle the same principal, operation, resource or relationship, and check with one verdict.
|
||||
</class_boundary>
|
||||
|
||||
@include(shared/exploitation/_task-formation-procedure.txt)
|
||||
@@ -0,0 +1,11 @@
|
||||
<role>
|
||||
You are an Injection Findings Reconciliation Specialist. Decide which current injection observations predict the same exploitation attempt and verdict.
|
||||
</role>
|
||||
|
||||
<class_boundary>
|
||||
One task is one attacker-controlled input reaching one dangerous operation in one injection context. Split independently controlled inputs, different contexts, or materially different defenses.
|
||||
|
||||
Read `source`, `combined_sources`, `path`, and `sink_call` as one data flow. Use `slot_type` and `sanitization_observed` to distinguish the injection context and its defense. A shared sink, CWE, file, line, payload, impact, or fix is supporting evidence only. Group only when one proof against one controlled input and dangerous operation would settle every observation with one verdict.
|
||||
</class_boundary>
|
||||
|
||||
@include(shared/exploitation/_task-formation-procedure.txt)
|
||||
@@ -0,0 +1,11 @@
|
||||
<role>
|
||||
You are a Generalist Findings Reconciliation Specialist. Decide which current observations in the internal miscellaneous class predict the same exploitation attempt and verdict.
|
||||
</role>
|
||||
|
||||
<class_boundary>
|
||||
One task is one attacker-controlled input or state driving one target operation to one security effect. A shared unsupported CWE does not justify a merge; require the same independently testable path and verdict.
|
||||
|
||||
This class spans unrelated weakness families. Read `vulnerable_code_location` and `source_endpoint` as context, `missing_defense` as the defect, and `observable_signal` and `proof_criterion` as the proof that must settle it. `exploitation_hypothesis` and `suggested_exploit_technique` are proposals, not identity. A shared CWE, rule, file, line, helper, impact, or fix is supporting evidence only.
|
||||
</class_boundary>
|
||||
|
||||
@include(shared/exploitation/_task-formation-procedure.txt)
|
||||
@@ -0,0 +1,11 @@
|
||||
<role>
|
||||
You are a Server-Side Request Forgery Findings Reconciliation Specialist. Decide which current SSRF observations predict the same exploitation attempt and verdict.
|
||||
</role>
|
||||
|
||||
<class_boundary>
|
||||
One task is one attacker-controlled input steering one outbound request operation. Split different controlled inputs, entry paths, controls, or outbound operations.
|
||||
|
||||
Read `source_endpoint` and `vulnerable_parameter` as the controlled entry path, and `vulnerable_code_location` as the outbound operation. Use `missing_defense` to distinguish the control being tested. `exploitation_hypothesis` and `suggested_exploit_technique` are proposals, not identity. A shared client helper, destination, CWE, file, line, impact, or fix is supporting evidence only.
|
||||
</class_boundary>
|
||||
|
||||
@include(shared/exploitation/_task-formation-procedure.txt)
|
||||
@@ -0,0 +1,11 @@
|
||||
<role>
|
||||
You are a Cross-Site Scripting Findings Reconciliation Specialist. Decide which current XSS observations predict the same exploitation attempt and verdict.
|
||||
</role>
|
||||
|
||||
<class_boundary>
|
||||
One task is one attacker-influenced value reaching one browser render context. Split different values, contexts, or trigger conditions.
|
||||
|
||||
Read `source`, `source_detail`, `path`, and `sink_function` as one content flow. Use `render_context` and `encoding_observed` to determine the browser context and the defense. Stored input and its later rendering can be two ends of one task, but two values or render contexts remain separate when one proof would not settle both. A shared component, route, sanitizer, CWE, file, line, payload, impact, or fix is supporting evidence only.
|
||||
</class_boundary>
|
||||
|
||||
@include(shared/exploitation/_task-formation-procedure.txt)
|
||||
@@ -1,86 +1,72 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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.
|
||||
|
||||
// Null Object pattern for audit logging - callers never check for null
|
||||
|
||||
import type { AuditSession } from '../audit/index.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { isLoggableAgentName, type LoggableAgentName, type SafeErrorDetails } from '../audit/safe-fields.js';
|
||||
|
||||
/**
|
||||
* Per-agent-run error audit sink. `createAuditLogger` always returns one of these
|
||||
* (never null), so a caller can log unconditionally without checking whether
|
||||
* audit is actually wired up for this run.
|
||||
*/
|
||||
export interface AuditLogger {
|
||||
logLlmResponse(turn: number, content: string): Promise<void>;
|
||||
logToolStart(toolName: string, parameters: unknown): Promise<void>;
|
||||
logToolEnd(result: unknown): Promise<void>;
|
||||
logError(error: Error, duration: number, turns: number): Promise<void>;
|
||||
logNote(category: string, message: string): Promise<void>;
|
||||
logError(error: SafeErrorDetails, duration: number, turns: number): Promise<void>;
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
|
||||
class RealAuditLogger implements AuditLogger {
|
||||
private auditSession: AuditSession;
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(auditSession: AuditSession) {
|
||||
this.auditSession = auditSession;
|
||||
constructor(
|
||||
private readonly auditSession: AuditSession,
|
||||
private readonly agentName: LoggableAgentName,
|
||||
private readonly attemptNumber: number,
|
||||
) {}
|
||||
|
||||
// Serializes writes onto one chain so concurrent calls append in call order rather than racing
|
||||
// on the underlying audit session, and swallows failures so a broken audit write never surfaces
|
||||
// as the agent's own error: recording an error must not itself risk failing the run.
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
this.queue = this.queue.then(operation, operation).catch(() => undefined);
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
async logLlmResponse(turn: number, content: string): Promise<void> {
|
||||
await this.auditSession.logEvent('llm_response', {
|
||||
turn,
|
||||
content,
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
logError(error: SafeErrorDetails, duration: number, turns: number): Promise<void> {
|
||||
return this.enqueue(() =>
|
||||
this.auditSession.logAgentError(this.agentName, error.code, error.category, this.attemptNumber, duration, turns),
|
||||
);
|
||||
}
|
||||
|
||||
async logToolStart(toolName: string, parameters: unknown): Promise<void> {
|
||||
await this.auditSession.logEvent('tool_start', {
|
||||
toolName,
|
||||
parameters,
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
async logToolEnd(result: unknown): Promise<void> {
|
||||
await this.auditSession.logEvent('tool_end', {
|
||||
result,
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
async logError(error: Error, duration: number, turns: number): Promise<void> {
|
||||
await this.auditSession.logEvent('error', {
|
||||
message: error.message,
|
||||
errorType: error.constructor.name,
|
||||
stack: error.stack,
|
||||
duration,
|
||||
turns,
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
async logNote(category: string, message: string): Promise<void> {
|
||||
await this.auditSession.logWorkflowNote(category, message);
|
||||
async flush(): Promise<void> {
|
||||
await this.queue;
|
||||
}
|
||||
}
|
||||
|
||||
/** Null Object implementation - all methods are safe no-ops */
|
||||
/** No-op sink for a run with no audit session or an agent name unsafe to log. */
|
||||
class NullAuditLogger implements AuditLogger {
|
||||
async logLlmResponse(_turn: number, _content: string): Promise<void> {}
|
||||
async logError(_error: SafeErrorDetails, _duration: number, _turns: number): Promise<void> {}
|
||||
|
||||
async logToolStart(_toolName: string, _parameters: unknown): Promise<void> {}
|
||||
|
||||
async logToolEnd(_result: unknown): Promise<void> {}
|
||||
|
||||
async logError(_error: Error, _duration: number, _turns: number): Promise<void> {}
|
||||
|
||||
async logNote(_category: string, _message: string): Promise<void> {}
|
||||
async flush(): Promise<void> {}
|
||||
}
|
||||
|
||||
// Returns no-op when auditSession is null
|
||||
export function createAuditLogger(auditSession: AuditSession | null): AuditLogger {
|
||||
if (auditSession) {
|
||||
return new RealAuditLogger(auditSession);
|
||||
/**
|
||||
* Build the error-audit sink for one agent attempt.
|
||||
*
|
||||
* Falls back to the null sink whenever real logging can't be done safely: no
|
||||
* audit session for this run, no agent name, or a name that isn't in the closed
|
||||
* loggable set (`isLoggableAgentName`). An unrecognized name is never written
|
||||
* to the durable audit trail, even as a bare string.
|
||||
*/
|
||||
export function createAuditLogger(
|
||||
auditSession: AuditSession | null,
|
||||
agentName: string | null,
|
||||
attemptNumber: number,
|
||||
): AuditLogger {
|
||||
if (auditSession !== null && agentName !== null && isLoggableAgentName(agentName)) {
|
||||
return new RealAuditLogger(auditSession, agentName, attemptNumber);
|
||||
}
|
||||
|
||||
return new NullAuditLogger();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -19,6 +19,13 @@
|
||||
*
|
||||
* Resolution returns a pi `Model` plus the `ModelRuntime` that owns its auth,
|
||||
* built over an in-memory credential store primed from the environment.
|
||||
*
|
||||
* The CLI cannot import this module (it ships as a separate bundle), so
|
||||
* `apps/cli/src/model-spec.ts` mirrors the parse rule and the provider/credential
|
||||
* tables by hand for its own `status` rendering and setup wizard. The two copies
|
||||
* have no shared compile-time link: a provider added or renamed on one side and
|
||||
* not the other does not fail to build, it just makes the CLI's guidance or
|
||||
* guard rails disagree with what the worker actually accepts at runtime.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
@@ -30,6 +37,11 @@ import { getAgentDir, ModelRuntime } from '@earendil-works/pi-coding-agent';
|
||||
* Providers Shannon curates with their own credential variables, config sections,
|
||||
* and setup flows. Each is a pi-ai provider id; any other pi provider is still
|
||||
* reachable through the generic credential path below.
|
||||
*
|
||||
* Kept identical to the CLI's own copy of this list (`apps/cli/src/model-spec.ts`),
|
||||
* which the CLI uses to decide whether "only one provider is configured" and to
|
||||
* gate its "Other provider" setup option. A curated provider missing from one
|
||||
* copy is silently treated as generic on that side.
|
||||
*/
|
||||
export const CURATED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
|
||||
|
||||
@@ -47,6 +59,11 @@ export const GENERIC_API_KEY_ENV = 'SHANNON_AI_API_KEY';
|
||||
* does not invent credential names — these are the variables each provider's own
|
||||
* tooling uses. Bedrock pairs its bearer token with AWS_REGION, which is provider
|
||||
* config rather than a credential.
|
||||
*
|
||||
* Mirrored by the CLI's own table of the same name, used there to decide which
|
||||
* env vars to forward into the worker container. A variable added here without
|
||||
* its CLI counterpart never reaches the container: the worker looks for a
|
||||
* credential the CLI never forwarded, and preflight reports it as absent.
|
||||
*/
|
||||
export const PROVIDER_API_KEY_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
|
||||
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
||||
@@ -232,10 +249,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 +342,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);
|
||||
@@ -333,10 +352,18 @@ export async function resolveModelSelection(): Promise<ModelSelection> {
|
||||
);
|
||||
}
|
||||
|
||||
let credentialSource: ModelSelection['credentialSource'] = 'ambient';
|
||||
if (mountedPiAuth) {
|
||||
credentialSource = 'pi-auth';
|
||||
} else if (credentials.apiKey) {
|
||||
credentialSource = 'api-key';
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
modelRuntime,
|
||||
modelId,
|
||||
providerId,
|
||||
credentialSource,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -14,6 +14,7 @@
|
||||
* a direct mapping.
|
||||
*/
|
||||
|
||||
import type { SafeErrorDetails } from '../audit/safe-fields.js';
|
||||
import { AGENTS } from '../session-manager.js';
|
||||
import { extractAgentType, formatDuration } from '../utils/formatting.js';
|
||||
import type { ExecutionContext } from './types.js';
|
||||
@@ -27,7 +28,10 @@ interface ToolCallInput {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Agent prefix used to attribute output when parallel agents interleave on one stream. */
|
||||
// Agent prefix used to attribute output when parallel agents interleave on one stream. Tries the
|
||||
// registered agent's exact display name first, then falls back to a keyword match against the raw
|
||||
// description, so a caller passing an ad hoc description string still gets a reasonable tag
|
||||
// instead of the generic one.
|
||||
export function getAgentPrefix(description: string): string {
|
||||
const agentPrefixes: Record<string, string> = {
|
||||
'injection-vuln': '[Injection]',
|
||||
@@ -68,7 +72,9 @@ function extractDomain(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a playwright-cli command (run via the bash tool) into a clean progress indicator. */
|
||||
// Browser automation goes through the bash tool as a playwright-cli invocation, not a dedicated
|
||||
// tool call, so there is no structured event to read the action from. This parses the command line
|
||||
// back into a friendly one-liner instead of showing the raw shell command.
|
||||
function formatBrowserAction(command: string): string | null {
|
||||
const match = command.match(/playwright-cli\s+(?:-s=\S+\s+)?(\S+)(?:\s+(.*))?/);
|
||||
if (!match) return null;
|
||||
@@ -139,7 +145,9 @@ function formatBrowserAction(command: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Summarize a todo_write update into a clean progress indicator. */
|
||||
// todo_write replaces the whole list on every call, so there is no single "changed item" to
|
||||
// report. Surface the most recently completed item if one exists, otherwise the item now in
|
||||
// progress; a list with neither (all pending, or empty) has nothing worth printing.
|
||||
function summarizeTodoUpdate(input: ToolCallInput | undefined): string | null {
|
||||
if (!input?.todos || !Array.isArray(input.todos)) {
|
||||
return null;
|
||||
@@ -159,6 +167,15 @@ function summarizeTodoUpdate(input: ToolCallInput | undefined): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a phase's console output style from its human-readable description.
|
||||
*
|
||||
* `isParallelExecution` marks the five concurrent vuln/exploit agents, whose output
|
||||
* interleaves on one stream and so needs a per-line agent tag; `useCleanOutput` marks
|
||||
* every phase that gets the friendly spinner-and-summary treatment instead of the
|
||||
* verbose turn-by-turn fallback. Matching is on substrings of `description`, the same
|
||||
* strings the activity layer passes as the human-facing phase label.
|
||||
*/
|
||||
export function detectExecutionContext(description: string): ExecutionContext {
|
||||
const isParallelExecution = description.includes('vuln agent') || description.includes('exploit agent');
|
||||
|
||||
@@ -236,36 +253,28 @@ export function formatToolCall(
|
||||
}
|
||||
|
||||
export function formatErrorOutput(
|
||||
error: Error & { code?: string; status?: number },
|
||||
error: SafeErrorDetails,
|
||||
context: ExecutionContext,
|
||||
description: string,
|
||||
duration: number,
|
||||
sourceDir: string,
|
||||
turns: number,
|
||||
isRetryable: boolean,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (context.isParallelExecution) {
|
||||
lines.push(`${getAgentPrefix(description)} Failed (${formatDuration(duration)})`);
|
||||
lines.push(`Agent failed (${formatDuration(duration)})`);
|
||||
} else if (context.useCleanOutput) {
|
||||
lines.push(`${context.agentType} failed (${formatDuration(duration)})`);
|
||||
} else {
|
||||
lines.push(` pi agent failed: ${description} (${formatDuration(duration)})`);
|
||||
lines.push(` Agent failed (${formatDuration(duration)})`);
|
||||
}
|
||||
|
||||
lines.push(` Error Type: ${error.constructor.name}`);
|
||||
lines.push(` Error Code: ${error.code}`);
|
||||
lines.push(` Category: ${error.category}`);
|
||||
lines.push(` Message: ${error.message}`);
|
||||
lines.push(` Agent: ${description}`);
|
||||
lines.push(` Working Directory: ${sourceDir}`);
|
||||
lines.push(` Turns: ${turns}`);
|
||||
lines.push(` Retryable: ${isRetryable ? 'Yes' : 'No'}`);
|
||||
|
||||
if (error.code) {
|
||||
lines.push(` Error Code: ${error.code}`);
|
||||
}
|
||||
if (error.status) {
|
||||
lines.push(` HTTP Status: ${error.status}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
// 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 { 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 { captureToolInvocation, decideToolOutcome } from '../../audit/trace.js';
|
||||
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;
|
||||
|
||||
// The closed set of stage-specific tools a caller is allowed to hand in alongside the confined
|
||||
// repository tools. Anything not on this list, and not a repository tool, is rejected as unknown
|
||||
// by validateCallerTools below.
|
||||
const CAPELLA_COLLECTOR_TOOL_NAMES = new Set([
|
||||
'report_finding',
|
||||
'record_duplicates',
|
||||
'record_review_verdict',
|
||||
'record_viability',
|
||||
'record_static_confirmation',
|
||||
'record_calibration',
|
||||
]);
|
||||
|
||||
// A Capella stage reasons over a read-only, confined view of the repository; none of these may
|
||||
// ever be offered to it. `bash`/`shell`/`network`/`browser`/`web_search` would give it an escape
|
||||
// hatch out of the confined tool set entirely; `edit`/`write` would let a review agent change the
|
||||
// code it is meant to only analyze; `task` would let it spawn further sessions outside this
|
||||
// executor's bounded turn/timeout accounting; `glob`/`ls`/`todo`/`todo_write` duplicate tools the
|
||||
// stage already gets from the confined factory or has no use for.
|
||||
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;
|
||||
// Per-session trace correlation lives here in the executor; the injected sink is a
|
||||
// stateless emitter, safe to share across the stage's sessions.
|
||||
const traceLog = request.log;
|
||||
const pendingTrace = new Map<string, { readonly tool: string; readonly startedAt: number }>();
|
||||
unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
||||
if (event.type === 'tool_execution_start') {
|
||||
operationCount += 1;
|
||||
if (traceLog !== undefined) {
|
||||
const invocation = captureToolInvocation(event.toolName, event.args);
|
||||
pendingTrace.set(event.toolCallId, { tool: event.toolName, startedAt: Date.now() });
|
||||
if (invocation !== undefined) traceLog.toolCall(invocation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type === 'tool_execution_end') {
|
||||
if (event.toolName === 'submit_result' && event.isError) invalidSubmission = true;
|
||||
if (traceLog !== undefined) {
|
||||
const pending = pendingTrace.get(event.toolCallId);
|
||||
if (pending !== undefined) {
|
||||
pendingTrace.delete(event.toolCallId);
|
||||
const outcome = decideToolOutcome(pending.tool, event.isError, Date.now() - pending.startedAt, undefined);
|
||||
if (outcome !== undefined) traceLog.toolOutcome(outcome);
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
const runStartedAt = Date.now();
|
||||
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);
|
||||
|
||||
// Emitted only past resolveOutcome so a failed, cancelled, timed-out, or turn-capped
|
||||
// session (all of which throw above) never reports a truthful-looking completion.
|
||||
if (traceLog !== undefined) {
|
||||
traceLog.sessionComplete(Date.now() - runStartedAt, turnCount, operationCount);
|
||||
}
|
||||
|
||||
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,66 @@
|
||||
// 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 { ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import type { TSchema } from 'typebox';
|
||||
import type { ToolInvocation, ToolOutcome } from '../../audit/trace.js';
|
||||
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;
|
||||
|
||||
/**
|
||||
* A sink for one Capella session's technical trace. The executor owns `toolCallId`
|
||||
* correlation and synchronously snapshots complete tool arguments before handing the
|
||||
* immutable invocation to the sink.
|
||||
*/
|
||||
export interface CapellaTraceLog {
|
||||
toolCall(invocation: ToolInvocation): void;
|
||||
toolOutcome(outcome: ToolOutcome): void;
|
||||
sessionComplete(durationMs: number, turns: number, operations: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One stage's trace surface. `forSession` binds a per-session view (its label becomes the trace
|
||||
* prefix's session component); all views share one serialized queue that `drain` awaits, so no
|
||||
* session's lines can still be buffered when its activity returns.
|
||||
*/
|
||||
export interface CapellaStageTrace {
|
||||
forSession(sessionLabel: string | undefined): CapellaTraceLog;
|
||||
drain(): Promise<void>;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
readonly log?: CapellaTraceLog;
|
||||
/**
|
||||
* Display-only session name for the trace prefix. Never hashed into `workloadId`, a checkpoint
|
||||
* key, a usage record, or a prompt; a stage may repeat or omit it without changing execution.
|
||||
*/
|
||||
readonly sessionLabel?: string;
|
||||
}
|
||||
|
||||
/** 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>>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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.
|
||||
|
||||
// Production agent execution on the pi harness, with git checkpoints and audit logging.
|
||||
// The checkpoint itself is created by the caller (AgentExecutionService) before and after
|
||||
// runPiPrompt runs; this module owns the session, its audit/error logging, and the trace it
|
||||
// produces, not the git commit around it.
|
||||
|
||||
import os from 'node:os';
|
||||
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import { fs, path } from 'zx';
|
||||
import type { AuditSession } from '../../audit/index.js';
|
||||
import { isLoggableAgentName, type SafeErrorDetails, safeErrorFromUnknown } from '../../audit/safe-fields.js';
|
||||
import { BASH_TIMEOUT_EXTENSION_DIR, deliverablesDir } from '../../paths.js';
|
||||
import { isRetryableFailure, PentestError } from '../../services/error-handling.js';
|
||||
import { AGENT_VALIDATORS } from '../../session-manager.js';
|
||||
@@ -44,6 +48,7 @@ import { permissionSystemConfigExists, permissionSystemPackageDir } from './perm
|
||||
import { PI_RETRY_SETTINGS } from './retry-settings.js';
|
||||
import { createGlobTool, createTodoWriteTool } from './session-tools.js';
|
||||
import { createTaskTool } from './task-tool.js';
|
||||
import { TraceEmitter } from './trace-emitter.js';
|
||||
import { providerTurnError } from './turn-error.js';
|
||||
|
||||
declare global {
|
||||
@@ -142,7 +147,6 @@ export interface PiPromptResult {
|
||||
model?: string | undefined;
|
||||
error?: string | undefined;
|
||||
errorType?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
retryable?: boolean | undefined;
|
||||
structuredOutput?: unknown;
|
||||
}
|
||||
@@ -154,18 +158,20 @@ function outputLines(lines: string[]): void {
|
||||
}
|
||||
|
||||
async function writeErrorLog(
|
||||
err: Error & { code?: string; status?: number },
|
||||
sourceDir: string,
|
||||
fullPrompt: string,
|
||||
error: SafeErrorDetails,
|
||||
duration: number,
|
||||
turns: number,
|
||||
retryable: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const errorLog = {
|
||||
timestamp: formatTimestamp(),
|
||||
agent: 'pi-executor',
|
||||
error: { name: err.constructor.name, message: err.message, code: err.code, status: err.status, stack: err.stack },
|
||||
context: { sourceDir, prompt: `${fullPrompt.slice(0, 200)}...`, retryable: isRetryableFailure(err) },
|
||||
error: { code: error.code, category: error.category, message: error.message },
|
||||
duration,
|
||||
turns,
|
||||
retryable,
|
||||
};
|
||||
const logPath = path.join(deliverablesDir(sourceDir), 'error.log');
|
||||
await fs.appendFile(logPath, `${JSON.stringify(errorLog)}\n`);
|
||||
@@ -186,6 +192,9 @@ export async function validateAgentOutput(
|
||||
logger.error('Validation failed: Agent execution was unsuccessful');
|
||||
return false;
|
||||
}
|
||||
// Not every agent has a deliverable-structure validator registered. Absence is not treated as
|
||||
// a failure: the agent already reported success above, so an agent with no validator passes on
|
||||
// that alone rather than being held to a check that was never defined for it.
|
||||
const validator = agentName ? AGENT_VALIDATORS[agentName as keyof typeof AGENT_VALIDATORS] : undefined;
|
||||
if (!validator) {
|
||||
logger.warn(`No validator found for agent "${agentName}" - assuming success`);
|
||||
@@ -230,6 +239,7 @@ export async function runPiPrompt(
|
||||
deliverablesSubdir?: string,
|
||||
cancellationSignal?: AbortSignal,
|
||||
submitTool?: CapturedSubmitTool,
|
||||
attemptNumber: number = 1,
|
||||
): Promise<PiPromptResult> {
|
||||
// 1. Initialize timing and prompt. A submit tool appends its directive so the
|
||||
// instruction to call it lives with the tool, not in every prompt file.
|
||||
@@ -243,7 +253,7 @@ export async function runPiPrompt(
|
||||
{ description, useCleanOutput: execContext.useCleanOutput },
|
||||
global.SHANNON_DISABLE_LOADER ?? false,
|
||||
);
|
||||
const auditLogger = createAuditLogger(auditSession);
|
||||
const auditLogger = createAuditLogger(auditSession, agentName, attemptNumber);
|
||||
|
||||
logger.info(`Running pi agent: ${description}...`);
|
||||
|
||||
@@ -259,6 +269,14 @@ export async function runPiPrompt(
|
||||
// plus any caller-supplied collector/submit tools).
|
||||
const selection = await resolveModelSelection();
|
||||
const resourceLoader = await buildResourceLoader(sourceDir, logger, agentName);
|
||||
const agentNameCandidate = agentName ?? '';
|
||||
const parentAgentName = isLoggableAgentName(agentNameCandidate) ? agentNameCandidate : 'pre-recon';
|
||||
// The durable trace log is path-addressed, so parent, child, and Capella writers all
|
||||
// reach the same file without sharing a stream handle.
|
||||
const workflowLogPath = auditSession?.workflowLogPath;
|
||||
const traceEmitter = workflowLogPath
|
||||
? new TraceEmitter(workflowLogPath, { kind: 'agent', agent: parentAgentName })
|
||||
: undefined;
|
||||
// Accumulates usage from in-process `task` child sessions so the parent's reported
|
||||
// cost includes sub-agent spend (their getSessionStats is separate from ours).
|
||||
const childUsage: ChildUsage = { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
||||
@@ -267,6 +285,11 @@ export async function runPiPrompt(
|
||||
model: selection.model,
|
||||
modelRuntime: selection.modelRuntime,
|
||||
cwd: sourceDir,
|
||||
parentAgentName,
|
||||
...(workflowLogPath !== undefined && { workflowLogPath }),
|
||||
...(traceEmitter !== undefined && {
|
||||
onDelegationStart: (child: string) => traceEmitter.delegationStart(child),
|
||||
}),
|
||||
onUsage: (usage) => {
|
||||
childUsage.cost += usage.cost;
|
||||
childUsage.inputTokens += usage.inputTokens;
|
||||
@@ -277,7 +300,7 @@ export async function runPiPrompt(
|
||||
resourceLoader,
|
||||
...(cancellationSignal && { cancellationSignal }),
|
||||
}),
|
||||
createTodoWriteTool(auditLogger),
|
||||
createTodoWriteTool(),
|
||||
createGlobTool(sourceDir),
|
||||
...(callerTools ?? []),
|
||||
...(submitTool ? [submitTool.tool] : []),
|
||||
@@ -330,7 +353,6 @@ export async function runPiPrompt(
|
||||
const msg = event.message;
|
||||
const text = extractAssistantText(msg);
|
||||
if (text.trim()) {
|
||||
void auditLogger.logLlmResponse(turnCount, text);
|
||||
progress.stop();
|
||||
outputLines(formatAssistantOutput(text, execContext, turnCount, description));
|
||||
progress.start();
|
||||
@@ -341,7 +363,8 @@ export async function runPiPrompt(
|
||||
break;
|
||||
}
|
||||
case 'tool_execution_start': {
|
||||
void auditLogger.logToolStart(event.toolName, event.args);
|
||||
const count = submitTool?.tool.name === event.toolName ? submitTool.safeCount : undefined;
|
||||
traceEmitter?.toolStart(event.toolCallId, event.toolName, event.args, count);
|
||||
const toolLines = formatToolCall(
|
||||
event.toolName,
|
||||
event.args as Record<string, unknown>,
|
||||
@@ -355,9 +378,10 @@ export async function runPiPrompt(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_execution_end':
|
||||
void auditLogger.logToolEnd(event.result);
|
||||
case 'tool_execution_end': {
|
||||
traceEmitter?.toolEnd(event.toolCallId, event.isError);
|
||||
break;
|
||||
}
|
||||
case 'compaction_end':
|
||||
if (!event.aborted && !event.willRetry && event.errorMessage) {
|
||||
pendingError =
|
||||
@@ -387,6 +411,8 @@ export async function runPiPrompt(
|
||||
// Capture the submit tool's structured payload so callers read it off the
|
||||
// result instead of holding a reference to the tool.
|
||||
const structuredOutput = submitTool?.getCaptured();
|
||||
await auditLogger.flush();
|
||||
await traceEmitter?.flush();
|
||||
|
||||
return {
|
||||
result,
|
||||
@@ -402,13 +428,17 @@ export async function runPiPrompt(
|
||||
...(structuredOutput !== undefined && { structuredOutput }),
|
||||
};
|
||||
} catch (error) {
|
||||
// 10. Handle errors — log, write error file, return failure
|
||||
// 9. Handle errors: log, write error file, return failure
|
||||
const duration = timer.stop();
|
||||
const err = error as Error & { code?: string; status?: number };
|
||||
await auditLogger.logError(err, duration, turnCount);
|
||||
const safeError = safeErrorFromUnknown(err);
|
||||
const retryable = isRetryableFailure(err);
|
||||
await auditLogger.logError(safeError, duration, turnCount);
|
||||
await auditLogger.flush();
|
||||
await traceEmitter?.flush();
|
||||
progress.stop();
|
||||
outputLines(formatErrorOutput(err, execContext, description, duration, sourceDir, isRetryableFailure(err)));
|
||||
await writeErrorLog(err, sourceDir, fullPrompt, duration);
|
||||
outputLines(formatErrorOutput(safeError, execContext, duration, turnCount, retryable));
|
||||
await writeErrorLog(sourceDir, safeError, duration, turnCount, retryable);
|
||||
|
||||
// A failed agent still spent money — on its own turns and, since Shannon's
|
||||
// prompts delegate the heavy work, mostly on `task` sub-agents. Both count
|
||||
@@ -416,9 +446,8 @@ export async function runPiPrompt(
|
||||
const usage = totalUsage(session, childUsage);
|
||||
|
||||
return {
|
||||
error: err.message,
|
||||
errorType: err instanceof PentestError && err.code ? err.code : err.constructor.name,
|
||||
prompt: `${fullPrompt.slice(0, 100)}...`,
|
||||
error: safeError.message,
|
||||
errorType: safeError.code,
|
||||
success: false,
|
||||
duration,
|
||||
turns: turnCount,
|
||||
@@ -427,7 +456,7 @@ export async function runPiPrompt(
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
cacheWriteTokens: usage.cacheWriteTokens,
|
||||
retryable: isRetryableFailure(err),
|
||||
retryable,
|
||||
};
|
||||
} finally {
|
||||
cancellationSignal?.removeEventListener('abort', onCancellation);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -8,32 +8,21 @@
|
||||
* Per-session custom tools registered for every agent: `todo_write` and `glob`.
|
||||
*
|
||||
* These replace harness built-ins that pi does not ship. `todo_write` is a
|
||||
* full-state-replace planning scratchpad mirrored to the workflow log; `glob` is
|
||||
* fast-glob file matching (pi has no `Glob` built-in).
|
||||
* full-state-replace planning scratchpad; `glob` is fast-glob file matching
|
||||
* (pi has no `Glob` built-in).
|
||||
*/
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import { fs, glob, path } from 'zx';
|
||||
|
||||
import type { AuditLogger } from '../audit-logger.js';
|
||||
|
||||
export interface TodoItem {
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
function renderTodos(todos: readonly TodoItem[]): string {
|
||||
const mark = (status: TodoItem['status']): string => {
|
||||
if (status === 'completed') return 'x';
|
||||
if (status === 'in_progress') return '~';
|
||||
return ' ';
|
||||
};
|
||||
return todos.map((todo) => `[${mark(todo.status)}] ${todo.content}`).join(' ');
|
||||
}
|
||||
|
||||
export function createTodoWriteTool(auditLogger: AuditLogger): ToolDefinition {
|
||||
export function createTodoWriteTool(): ToolDefinition {
|
||||
let current: TodoItem[] = [];
|
||||
|
||||
return defineTool({
|
||||
@@ -56,7 +45,6 @@ export function createTodoWriteTool(auditLogger: AuditLogger): ToolDefinition {
|
||||
async execute(_toolCallId, params) {
|
||||
current = params.todos as TodoItem[];
|
||||
const completed = current.filter((todo) => todo.status === 'completed').length;
|
||||
await auditLogger.logNote('todo', renderTodos(current));
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
/** Attempt-local working-tree copy used by the task-formation model boundary. */
|
||||
|
||||
import type { Dirent, Stats } from 'node:fs';
|
||||
import { cp, lstat, mkdir, mkdtemp, readdir, realpath, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { ArtifactIntegrityError, ReconciliationIoError } from '../reconciliation/artifact-store.js';
|
||||
|
||||
const JAIL_PREFIX = 'shannon-task-formation-';
|
||||
// Never copied into the model-readable jail: `.git` carries deliverables history, `.shannon` holds
|
||||
// scan internals, and `.pi` holds provider credentials. Any of these reaching the jail would expose
|
||||
// them to the tools the model drives. The post-copy verification re-checks their absence by name.
|
||||
const ALWAYS_EXCLUDED_NAMES = Object.freeze(['.git', '.shannon', '.pi'] as const);
|
||||
|
||||
export interface SourceJailOptions {
|
||||
readonly sourceRoot: string;
|
||||
readonly deliverablesPath: string;
|
||||
readonly reconciliationWorkspacePath: string;
|
||||
readonly signal?: AbortSignal;
|
||||
/** Test-only filesystem selector. Production uses `os.tmpdir()`. */
|
||||
readonly tempRoot?: string;
|
||||
}
|
||||
|
||||
/** One source-only jail plus the immutable deny rules used by its live tool gate. */
|
||||
export interface SourceJail {
|
||||
readonly dir: string;
|
||||
readonly deniedPaths: readonly string[];
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
function cancellationError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason;
|
||||
return new DOMException('Task formation was cancelled.', 'AbortError');
|
||||
}
|
||||
|
||||
function checkCancellation(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted === true) throw cancellationError(signal);
|
||||
}
|
||||
|
||||
// Path-confinement predicate: true only when `candidate` is `root` itself or lies beneath it.
|
||||
// A relative path that escapes upward (`..`) or is absolute means the candidate is outside the root.
|
||||
function isWithin(root: string, candidate: string): boolean {
|
||||
const relativePath = path.relative(root, candidate);
|
||||
return (
|
||||
relativePath === '' ||
|
||||
(!relativePath.startsWith(`..${path.sep}`) && relativePath !== '..' && !path.isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
async function relativeExclusion(
|
||||
sourceRoot: string,
|
||||
lexicalSourceRoot: string,
|
||||
candidate: string,
|
||||
): Promise<string | undefined> {
|
||||
const resolved = path.resolve(candidate);
|
||||
let relativePath: string | undefined;
|
||||
if (isWithin(sourceRoot, resolved)) {
|
||||
relativePath = path.relative(sourceRoot, resolved);
|
||||
} else if (isWithin(lexicalSourceRoot, resolved)) {
|
||||
relativePath = path.relative(lexicalSourceRoot, resolved);
|
||||
} else {
|
||||
try {
|
||||
const canonicalCandidate = await realpath(resolved);
|
||||
if (isWithin(sourceRoot, canonicalCandidate)) {
|
||||
relativePath = path.relative(sourceRoot, canonicalCandidate);
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (relativePath === undefined) return undefined;
|
||||
|
||||
if (relativePath === '') {
|
||||
// An exclusion that resolves to the whole root would empty the jail. Fail closed rather than
|
||||
// copy nothing and hand the model an empty tree.
|
||||
throw new ArtifactIntegrityError('A task-formation exclusion resolves to the complete source root');
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
async function buildDynamicExclusions(
|
||||
options: SourceJailOptions,
|
||||
sourceRoot: string,
|
||||
lexicalSourceRoot: string,
|
||||
): Promise<readonly string[]> {
|
||||
const exclusions = (
|
||||
await Promise.all([
|
||||
relativeExclusion(sourceRoot, lexicalSourceRoot, options.deliverablesPath),
|
||||
relativeExclusion(sourceRoot, lexicalSourceRoot, options.reconciliationWorkspacePath),
|
||||
])
|
||||
).filter((value): value is string => value !== undefined);
|
||||
return Object.freeze([...new Set(exclusions)]);
|
||||
}
|
||||
|
||||
function pathHasAlwaysExcludedName(relativePath: string): boolean {
|
||||
const segments = relativePath.split(path.sep);
|
||||
return segments.some((segment) => (ALWAYS_EXCLUDED_NAMES as readonly string[]).includes(segment));
|
||||
}
|
||||
|
||||
function pathIsDynamicallyExcluded(relativePath: string, exclusions: readonly string[]): boolean {
|
||||
return exclusions.some((excluded) => relativePath === excluded || relativePath.startsWith(`${excluded}${path.sep}`));
|
||||
}
|
||||
|
||||
async function copySourceTree(
|
||||
sourceRoot: string,
|
||||
destination: string,
|
||||
dynamicExclusions: readonly string[],
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = (await readdir(sourceRoot, { withFileTypes: true })).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to enumerate the task-formation source tree');
|
||||
}
|
||||
|
||||
// Cancellation is checked before every top-level entry and inside the copy filter so an aborted
|
||||
// scan stops promptly instead of copying a whole large tree first.
|
||||
for (const entry of entries) {
|
||||
checkCancellation(signal);
|
||||
const source = path.join(sourceRoot, entry.name);
|
||||
const destinationEntry = path.join(destination, entry.name);
|
||||
try {
|
||||
// verbatimSymlinks copies links as links rather than following them, so a link pointing
|
||||
// outside the tree cannot pull external content in; the filter then drops any path that
|
||||
// resolves outside the root, plus the always- and dynamically-excluded paths.
|
||||
await cp(source, destinationEntry, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
async filter(candidate) {
|
||||
checkCancellation(signal);
|
||||
const relativePath = path.relative(sourceRoot, candidate);
|
||||
if (relativePath === '' || !isWithin(sourceRoot, path.resolve(candidate))) return false;
|
||||
if (pathHasAlwaysExcludedName(relativePath)) return false;
|
||||
return !pathIsDynamicallyExcluded(relativePath, dynamicExclusions);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted === true) throw cancellationError(signal);
|
||||
if (error instanceof ArtifactIntegrityError) throw error;
|
||||
throw new ReconciliationIoError('Unable to copy the task-formation source tree');
|
||||
}
|
||||
}
|
||||
checkCancellation(signal);
|
||||
}
|
||||
|
||||
async function assertAlwaysExcludedNamesAbsent(directory: string, signal: AbortSignal | undefined): Promise<void> {
|
||||
checkCancellation(signal);
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to verify the task-formation source jail');
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
checkCancellation(signal);
|
||||
if ((ALWAYS_EXCLUDED_NAMES as readonly string[]).includes(entry.name)) {
|
||||
throw new ArtifactIntegrityError('The task-formation source jail contains an excluded entry');
|
||||
}
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
await assertAlwaysExcludedNamesAbsent(path.join(directory, entry.name), signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertDynamicExclusionsAbsent(
|
||||
directory: string,
|
||||
exclusions: readonly string[],
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> {
|
||||
for (const excluded of exclusions) {
|
||||
checkCancellation(signal);
|
||||
try {
|
||||
await lstat(path.join(directory, excluded));
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) continue;
|
||||
throw new ReconciliationIoError('Unable to verify a task-formation jail exclusion');
|
||||
}
|
||||
throw new ArtifactIntegrityError('The task-formation source jail contains a protected workspace entry');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-verify the copied tree independently of the copy filter: the jail root must be a real
|
||||
// directory (not a symlink), and no excluded name or protected workspace path may survive. This
|
||||
// catches a filter gap or a race during the copy before the model is allowed to read the tree.
|
||||
async function verifyJail(
|
||||
directory: string,
|
||||
dynamicExclusions: readonly string[],
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> {
|
||||
checkCancellation(signal);
|
||||
let stats: Stats;
|
||||
try {
|
||||
stats = await lstat(directory);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to inspect the task-formation source jail');
|
||||
}
|
||||
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
||||
throw new ArtifactIntegrityError('The task-formation source jail is not a real directory');
|
||||
}
|
||||
await assertAlwaysExcludedNamesAbsent(directory, signal);
|
||||
await assertDynamicExclusionsAbsent(directory, dynamicExclusions, signal);
|
||||
checkCancellation(signal);
|
||||
}
|
||||
|
||||
async function removeJail(directory: string): Promise<void> {
|
||||
try {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to remove the task-formation source jail');
|
||||
}
|
||||
|
||||
try {
|
||||
await lstat(directory);
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) return;
|
||||
throw new ReconciliationIoError('Unable to verify task-formation source-jail cleanup');
|
||||
}
|
||||
throw new ReconciliationIoError('Task-formation source-jail cleanup left the jail on disk');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the scanned working tree into an isolated temporary directory without following symlinks.
|
||||
* Every failure removes the attempt-local directory before it propagates.
|
||||
*/
|
||||
export async function materializeSourceJail(options: SourceJailOptions): Promise<SourceJail> {
|
||||
checkCancellation(options.signal);
|
||||
|
||||
const lexicalSourceRoot = path.resolve(options.sourceRoot);
|
||||
let sourceRoot: string;
|
||||
try {
|
||||
sourceRoot = await realpath(options.sourceRoot);
|
||||
const sourceStats = await lstat(sourceRoot);
|
||||
if (sourceStats.isSymbolicLink() || !sourceStats.isDirectory()) {
|
||||
throw new ArtifactIntegrityError('The task-formation source root is not a real directory');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ArtifactIntegrityError) throw error;
|
||||
throw new ReconciliationIoError('Unable to resolve the task-formation source root');
|
||||
}
|
||||
|
||||
let tempRoot: string;
|
||||
try {
|
||||
const configuredTempRoot = options.tempRoot ?? os.tmpdir();
|
||||
await mkdir(configuredTempRoot, { recursive: true });
|
||||
tempRoot = await realpath(configuredTempRoot);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to resolve the task-formation temporary root');
|
||||
}
|
||||
// A temp root inside the source tree would make the copy try to copy the jail into itself.
|
||||
if (isWithin(sourceRoot, tempRoot)) {
|
||||
throw new ArtifactIntegrityError('The task-formation temporary root cannot be inside the source tree');
|
||||
}
|
||||
|
||||
const dynamicExclusions = await buildDynamicExclusions(options, sourceRoot, lexicalSourceRoot);
|
||||
let directory: string;
|
||||
try {
|
||||
directory = await mkdtemp(path.join(tempRoot, JAIL_PREFIX));
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to create the task-formation source jail');
|
||||
}
|
||||
|
||||
let cleaned = false;
|
||||
const cleanup = async (): Promise<void> => {
|
||||
if (cleaned) return;
|
||||
await removeJail(directory);
|
||||
cleaned = true;
|
||||
};
|
||||
|
||||
try {
|
||||
await copySourceTree(sourceRoot, directory, dynamicExclusions, options.signal);
|
||||
await verifyJail(directory, dynamicExclusions, options.signal);
|
||||
} catch (error) {
|
||||
await cleanup().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const deniedPaths = Object.freeze([...ALWAYS_EXCLUDED_NAMES, ...dynamicExclusions]);
|
||||
return Object.freeze({ dir: directory, deniedPaths, cleanup });
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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 { AssistantMessage, Context, ToolCall } from '@earendil-works/pi-ai';
|
||||
import { Value } from 'typebox/value';
|
||||
import { providerFailureSentence } from '../../services/error-handling.js';
|
||||
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;
|
||||
|
||||
// True only when this caller's own signal aborted and the error traces back to it. Walk a bounded,
|
||||
// cycle-guarded cause chain so a cancellation wrapped several layers deep is still recognized as a
|
||||
// cancellation and not misreported as a provider error. Without the `signal.aborted` gate an
|
||||
// unrelated AbortError from the provider could be mistaken for our cancellation.
|
||||
function isSignalCancellation(error: unknown, signal: AbortSignal | undefined): boolean {
|
||||
if (signal?.aborted !== true) return false;
|
||||
|
||||
let current: unknown = error;
|
||||
const seen = new Set<unknown>();
|
||||
for (let depth = 0; depth < 8 && current !== undefined && current !== null && !seen.has(current); depth++) {
|
||||
if (current === signal.reason) return true;
|
||||
seen.add(current);
|
||||
const errorName = current instanceof Error ? current.name : undefined;
|
||||
if (errorName === 'AbortError' || errorName === 'CancelledFailure') return true;
|
||||
current = current instanceof Error ? current.cause : undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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 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 {
|
||||
const selection = await host.resolve('small');
|
||||
// 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 (isSignalCancellation(error, request.signal)) {
|
||||
return { stopReason: 'aborted', toolCalls: [], usage: ZERO_USAGE };
|
||||
}
|
||||
const failure = host.classify(error);
|
||||
return {
|
||||
stopReason: 'error',
|
||||
toolCalls: [],
|
||||
usage: ZERO_USAGE,
|
||||
errorMessage: providerFailureSentence(failure),
|
||||
providerFailure: { type: failure.type, retryable: failure.retryable },
|
||||
};
|
||||
}
|
||||
|
||||
if (response.stopReason === 'error') {
|
||||
const failure = host.classify(response);
|
||||
return {
|
||||
stopReason: 'error',
|
||||
toolCalls: [],
|
||||
usage: responseUsage(response),
|
||||
errorMessage: providerFailureSentence(failure),
|
||||
providerFailure: { type: failure.type, retryable: failure.retryable },
|
||||
};
|
||||
}
|
||||
if (response.stopReason === 'aborted') {
|
||||
// An abort with our signal set is a real cancellation. An abort without it is a provider-side
|
||||
// stop we did not ask for, so classify it as an error the caller can retry on.
|
||||
if (request.signal?.aborted === true) {
|
||||
return { stopReason: 'aborted', toolCalls: [], usage: responseUsage(response) };
|
||||
}
|
||||
const failure = host.classify(response);
|
||||
return {
|
||||
stopReason: 'error',
|
||||
toolCalls: [],
|
||||
usage: responseUsage(response),
|
||||
errorMessage: providerFailureSentence(failure),
|
||||
providerFailure: { type: failure.type, retryable: failure.retryable },
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
// 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.
|
||||
|
||||
/** Dedicated read-only Pi executor and live tool policy for Pass 1 task formation. */
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
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 } from 'typebox';
|
||||
import { providerFailureSentence } from '../../services/error-handling.js';
|
||||
import type { ProviderFailure } from '../../types/errors.js';
|
||||
import { type ModelHost, modelHost } from '../model-host.js';
|
||||
import type { ModelSelection } from '../models.js';
|
||||
import type { ValidatingSubmitTool } from '../reconciliation/submit-validation.js';
|
||||
import { ConfinementError, compileRepositoryGlob, RepositoryConfinement } from '../sast/capella/tools/confinement.js';
|
||||
import { createCapellaRepositoryTools } from '../sast/capella/tools/repository-tools.js';
|
||||
import { PI_RETRY_SETTINGS } from './retry-settings.js';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1_000;
|
||||
const DEFAULT_MAX_TURNS = 64;
|
||||
const MAX_TIMEOUT_MS = 30 * 60 * 1_000;
|
||||
const MAX_TURNS = 128;
|
||||
const MAX_LIST_RESULTS = 500;
|
||||
const DEFAULT_LIST_RESULTS = 200;
|
||||
const MAX_OUTPUT_BYTES = 64 * 1024;
|
||||
// The live-tool-side counterpart of the source jail's copy-time exclusion (source-jail.ts): even if
|
||||
// one of these somehow existed in the jailed tree, the read/grep/find/ls/glob tools built below must
|
||||
// still refuse to serve it. `.git` is deliverables history, `.shannon` is scan internals, `.pi` is
|
||||
// provider credentials.
|
||||
const ALWAYS_DENIED_PATHS = Object.freeze(['.git', '.shannon', '.pi'] as const);
|
||||
const TRANSIENT_IO_CODES = new Set([
|
||||
'EAGAIN',
|
||||
'EBUSY',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'EIO',
|
||||
'EMFILE',
|
||||
'ENFILE',
|
||||
'ENOMEM',
|
||||
'ENOSPC',
|
||||
'EPIPE',
|
||||
'EROFS',
|
||||
'ETIMEDOUT',
|
||||
]);
|
||||
|
||||
export const TASK_FORMATION_TOOL_NAMES = Object.freeze([
|
||||
'read',
|
||||
'grep',
|
||||
'find',
|
||||
'ls',
|
||||
'glob',
|
||||
'submit_result',
|
||||
] as const);
|
||||
|
||||
// The closed set of failure reasons the integration layer accepts as grounds to fall back to a
|
||||
// single-agent formation. Only a failure carrying one of these becomes a fallback; any other
|
||||
// failure propagates. Keep this in sync with the reasons the Temporal caller recognizes.
|
||||
export const TASK_FORMATION_FALLBACK_REASONS = Object.freeze([
|
||||
'retryable_model_failure',
|
||||
'missing_accepted_submission',
|
||||
'model_stage_timeout',
|
||||
] as const);
|
||||
|
||||
export type TaskFormationFallbackReason = (typeof TASK_FORMATION_FALLBACK_REASONS)[number];
|
||||
|
||||
export type TaskFormationExecutorFailureKind = 'model' | 'input' | 'confinement' | 'infrastructure';
|
||||
|
||||
/** Safe, bounded fields supplied by the activity wrapper for per-attempt executor correlation. */
|
||||
export interface TaskFormationExecutionContext {
|
||||
readonly executionKey?: string;
|
||||
readonly attempt?: number;
|
||||
readonly stage?: string;
|
||||
readonly vulnerabilityClass?: string;
|
||||
}
|
||||
|
||||
export interface TaskFormationUsage {
|
||||
readonly costUsd: number;
|
||||
readonly inputTokens: number;
|
||||
readonly outputTokens: number;
|
||||
}
|
||||
|
||||
export class TaskFormationExecutorError extends Error {
|
||||
override readonly name = 'TaskFormationExecutorError';
|
||||
readonly code: string;
|
||||
readonly retryable: boolean;
|
||||
readonly failureKind: TaskFormationExecutorFailureKind;
|
||||
readonly fallbackReason: TaskFormationFallbackReason | undefined;
|
||||
readonly usage: TaskFormationUsage;
|
||||
readonly modelCalls: number;
|
||||
|
||||
constructor(options: {
|
||||
code: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
failureKind: TaskFormationExecutorFailureKind;
|
||||
fallbackReason?: TaskFormationFallbackReason;
|
||||
usage?: TaskFormationUsage;
|
||||
modelCalls?: number;
|
||||
}) {
|
||||
super(options.message);
|
||||
this.code = options.code;
|
||||
this.retryable = options.retryable;
|
||||
this.failureKind = options.failureKind;
|
||||
this.fallbackReason = options.fallbackReason;
|
||||
this.usage = options.usage ?? zeroUsage();
|
||||
this.modelCalls = options.modelCalls ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TaskFormationExecutorRequest {
|
||||
readonly cwd: string;
|
||||
readonly systemPrompt: string;
|
||||
readonly modelContext: string;
|
||||
readonly deniedPaths: readonly string[];
|
||||
readonly submitTool: ValidatingSubmitTool;
|
||||
readonly signal: AbortSignal;
|
||||
readonly timeoutMs?: number;
|
||||
readonly maxTurns?: number;
|
||||
readonly correlation?: TaskFormationExecutionContext;
|
||||
}
|
||||
|
||||
export interface TaskFormationExecutorResult {
|
||||
readonly output: unknown;
|
||||
readonly usage: TaskFormationUsage;
|
||||
readonly providerId: string;
|
||||
readonly modelId: string;
|
||||
readonly modelCalls: 1;
|
||||
readonly registeredTools: readonly string[];
|
||||
}
|
||||
|
||||
export interface TaskFormationExecutor {
|
||||
run(request: TaskFormationExecutorRequest): Promise<TaskFormationExecutorResult>;
|
||||
}
|
||||
|
||||
interface SessionOutcome {
|
||||
readonly pendingProviderError: unknown;
|
||||
readonly promptError: unknown;
|
||||
readonly usage: TaskFormationUsage;
|
||||
}
|
||||
|
||||
interface ToolFactoryOptions {
|
||||
readonly cwd: string;
|
||||
readonly deniedPaths: readonly string[];
|
||||
}
|
||||
|
||||
function zeroUsage(): TaskFormationUsage {
|
||||
return { costUsd: 0, inputTokens: 0, outputTokens: 0 };
|
||||
}
|
||||
|
||||
/** Reject unknown values from Temporal failure details instead of widening semantic fallback. */
|
||||
export function isTaskFormationFallbackReason(value: unknown): value is TaskFormationFallbackReason {
|
||||
return (TASK_FORMATION_FALLBACK_REASONS as readonly unknown[]).includes(value);
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
|
||||
return typeof error.code === 'string' ? error.code : undefined;
|
||||
}
|
||||
|
||||
function isTransientIoFailure(error: unknown): boolean {
|
||||
const code = errorCode(error);
|
||||
if (code !== undefined && TRANSIENT_IO_CODES.has(code)) return true;
|
||||
if (error instanceof Error && error.cause !== undefined) return isTransientIoFailure(error.cause);
|
||||
return false;
|
||||
}
|
||||
|
||||
function safeIdentifier(value: string | undefined): string | undefined {
|
||||
if (value === undefined || !/^[A-Za-z0-9._:-]{1,128}$/u.test(value)) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Emit only bounded, format-checked correlation fields. Prompt text, model context, and source
|
||||
// content never enter the log line. An unsafe or missing identifier falls back to a synthetic one
|
||||
// rather than logging the caller's raw value.
|
||||
function executionLogContext(context: TaskFormationExecutionContext | undefined): Readonly<Record<string, unknown>> {
|
||||
const attempt = context?.attempt;
|
||||
return Object.freeze({
|
||||
executionKey: safeIdentifier(context?.executionKey) ?? randomUUID(),
|
||||
attempt: Number.isSafeInteger(attempt) && (attempt ?? 0) > 0 ? attempt : null,
|
||||
stage: safeIdentifier(context?.stage) ?? 'task-formation',
|
||||
class: safeIdentifier(context?.vulnerabilityClass) ?? 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
function finiteNonNegative(value: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
}
|
||||
|
||||
function sessionUsage(session: AgentSession): TaskFormationUsage {
|
||||
const stats = session.getSessionStats();
|
||||
return {
|
||||
costUsd: finiteNonNegative(stats.cost),
|
||||
inputTokens: finiteNonNegative(stats.tokens.input),
|
||||
outputTokens: finiteNonNegative(stats.tokens.output),
|
||||
};
|
||||
}
|
||||
|
||||
function boundedText(value: string): string {
|
||||
const bytes = Buffer.from(value, 'utf8');
|
||||
if (bytes.byteLength <= MAX_OUTPUT_BYTES) return value;
|
||||
return bytes.subarray(0, MAX_OUTPUT_BYTES).toString('utf8');
|
||||
}
|
||||
|
||||
function uniqueDeniedPaths(deniedPaths: readonly string[]): readonly string[] {
|
||||
return Object.freeze([...new Set([...ALWAYS_DENIED_PATHS, ...deniedPaths])]);
|
||||
}
|
||||
|
||||
// The session must register exactly the allowlisted tools. This is checked against the built tool
|
||||
// set and again against the live session's registered tools, so an injected or dropped tool fails
|
||||
// the session closed before the model runs.
|
||||
function hasExactToolSet(toolNames: readonly string[]): boolean {
|
||||
const expected = [...TASK_FORMATION_TOOL_NAMES].sort();
|
||||
const actual = [...toolNames].sort();
|
||||
return actual.length === expected.length && actual.every((name, index) => name === expected[index]);
|
||||
}
|
||||
|
||||
function createListTool(confinement: RepositoryConfinement): ToolDefinition {
|
||||
return defineTool({
|
||||
name: 'ls',
|
||||
label: 'List source directory',
|
||||
description: 'List bounded repository-relative entries without following symlinks.',
|
||||
promptSnippet: 'ls: list entries below one source directory',
|
||||
promptGuidelines: ['Use a repository-relative directory. Absolute paths and traversal are rejected.'],
|
||||
parameters: Type.Object(
|
||||
{
|
||||
path: Type.Optional(Type.String({ minLength: 1, maxLength: 1_024 })),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_LIST_RESULTS })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
async execute(_toolCallId, parameters, signal) {
|
||||
const requestedPath = parameters.path ?? '.';
|
||||
const budget = confinement.createBudget(signal);
|
||||
const searchRoot = await confinement.resolveExisting(requestedPath, true, budget);
|
||||
const entries = await confinement.enumerate(requestedPath, signal, budget);
|
||||
const names = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
confinement.checkBudget(budget);
|
||||
const relativePath = pathRelative(searchRoot, entry.absolutePath);
|
||||
const [first, ...remaining] = relativePath.split('/');
|
||||
if (first) names.add(remaining.length > 0 ? `${first}/` : first);
|
||||
}
|
||||
|
||||
const limit = parameters.limit ?? DEFAULT_LIST_RESULTS;
|
||||
const output = [...names].sort().slice(0, limit);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: boundedText(output.join('\n') || 'No entries found.') }],
|
||||
details: { count: output.length, truncated: names.size > output.length },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function pathRelative(root: string, candidate: string): string {
|
||||
const relativePath = path.relative(root, candidate);
|
||||
if (
|
||||
!relativePath ||
|
||||
relativePath.startsWith(`..${path.sep}`) ||
|
||||
relativePath === '..' ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'TOOL_PATH_RACE',
|
||||
message: 'Task-formation source path changed during access.',
|
||||
retryable: false,
|
||||
failureKind: 'confinement',
|
||||
});
|
||||
}
|
||||
return relativePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function createGlobTool(confinement: RepositoryConfinement): ToolDefinition {
|
||||
return defineTool({
|
||||
name: 'glob',
|
||||
label: 'Glob source files',
|
||||
description: 'Match bounded file globs from the source-jail root without following symlinks.',
|
||||
promptSnippet: 'glob: match source files from the jail root',
|
||||
promptGuidelines: ['Patterns are always rooted in the source jail.'],
|
||||
parameters: Type.Object(
|
||||
{
|
||||
pattern: Type.String({ minLength: 1, maxLength: 256 }),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_LIST_RESULTS })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
async execute(_toolCallId, parameters, signal) {
|
||||
const budget = confinement.createBudget(signal);
|
||||
const matcher = compileRepositoryGlob(parameters.pattern);
|
||||
const entries = await confinement.enumerate('.', signal, budget);
|
||||
const limit = parameters.limit ?? DEFAULT_LIST_RESULTS;
|
||||
const matches: string[] = [];
|
||||
let truncated = false;
|
||||
for (const entry of entries) {
|
||||
confinement.checkBudget(budget);
|
||||
if (!matcher.test(entry.path)) continue;
|
||||
if (matches.length >= limit) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
matches.push(entry.path);
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: boundedText(matches.join('\n') || 'No files found.') }],
|
||||
details: { count: matches.length, truncated },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Create the five code-owned source tools that share one canonical jail policy. */
|
||||
export async function createTaskFormationSourceTools(options: ToolFactoryOptions): Promise<readonly ToolDefinition[]> {
|
||||
const deniedPaths = uniqueDeniedPaths(options.deniedPaths);
|
||||
const capellaTools = await createCapellaRepositoryTools({
|
||||
repositoryRoot: options.cwd,
|
||||
deniedPaths,
|
||||
});
|
||||
const confinement = await RepositoryConfinement.create({
|
||||
repositoryRoot: options.cwd,
|
||||
deniedPaths,
|
||||
});
|
||||
const byName = new Map(capellaTools.map((tool) => [tool.name, tool]));
|
||||
const tools = [
|
||||
byName.get('read'),
|
||||
byName.get('grep'),
|
||||
byName.get('find'),
|
||||
createListTool(confinement),
|
||||
createGlobTool(confinement),
|
||||
];
|
||||
if (tools.some((tool) => tool === undefined)) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'TOOL_FACTORY_MISMATCH',
|
||||
message: 'Task-formation source tool factory returned an incomplete set.',
|
||||
retryable: false,
|
||||
failureKind: 'confinement',
|
||||
});
|
||||
}
|
||||
return Object.freeze(tools as ToolDefinition[]);
|
||||
}
|
||||
|
||||
function cancellationError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason;
|
||||
return new DOMException('Task formation was 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 validateRequest(request: TaskFormationExecutorRequest): { timeoutMs: number; maxTurns: number } {
|
||||
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const maxTurns = request.maxTurns ?? DEFAULT_MAX_TURNS;
|
||||
if (!request.cwd || !request.systemPrompt || !request.modelContext) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'Task-formation executor input is incomplete.',
|
||||
retryable: false,
|
||||
failureKind: 'input',
|
||||
});
|
||||
}
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'INVALID_TIMEOUT',
|
||||
message: `Task-formation timeout must be a positive integer no greater than ${MAX_TIMEOUT_MS} milliseconds.`,
|
||||
retryable: false,
|
||||
failureKind: 'input',
|
||||
});
|
||||
}
|
||||
if (!Number.isInteger(maxTurns) || maxTurns < 1 || maxTurns > MAX_TURNS) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'INVALID_TURN_LIMIT',
|
||||
message: 'Task-formation turn limit is outside its bounded range.',
|
||||
retryable: false,
|
||||
failureKind: 'input',
|
||||
});
|
||||
}
|
||||
return { timeoutMs, maxTurns };
|
||||
}
|
||||
|
||||
function isAbortLike(error: unknown): boolean {
|
||||
return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
|
||||
}
|
||||
|
||||
function classifyModelFailure(host: ModelHost, error: unknown): ProviderFailure {
|
||||
if (isAbortLike(error)) {
|
||||
return {
|
||||
type: 'AgentExecutionError',
|
||||
category: 'transport',
|
||||
retryable: true,
|
||||
message: 'The provider request ended before task formation completed.',
|
||||
};
|
||||
}
|
||||
return host.classify(error);
|
||||
}
|
||||
|
||||
function executorLog(level: 'info' | 'warn', fields: Readonly<Record<string, unknown>>): void {
|
||||
console[level](JSON.stringify({ component: 'task-formation-executor', ...fields }));
|
||||
}
|
||||
|
||||
class StandaloneTaskFormationExecutor implements TaskFormationExecutor {
|
||||
private readonly host: ModelHost;
|
||||
|
||||
constructor(host: ModelHost) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
async run(request: TaskFormationExecutorRequest): Promise<TaskFormationExecutorResult> {
|
||||
const logContext = executionLogContext(request.correlation);
|
||||
let timeoutMs: number;
|
||||
let maxTurns: number;
|
||||
try {
|
||||
({ timeoutMs, maxTurns } = validateRequest(request));
|
||||
} catch (error) {
|
||||
const failure = this.normalizeFailure(error);
|
||||
executorLog('warn', {
|
||||
...logContext,
|
||||
event: 'finished',
|
||||
outcome: 'failed',
|
||||
code: failure.code,
|
||||
failureKind: failure.failureKind,
|
||||
retryable: failure.retryable,
|
||||
});
|
||||
throw failure;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let termination: 'cancellation' | 'timeout' | 'turn-limit' | undefined;
|
||||
let session: AgentSession | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
let requestStarted = false;
|
||||
let turnCount = 0;
|
||||
|
||||
const terminate = (reason: 'cancellation' | 'timeout' | 'turn-limit'): void => {
|
||||
if (termination !== undefined) return;
|
||||
termination = reason;
|
||||
controller.abort(new DOMException(`Task-formation session ${reason}.`, 'AbortError'));
|
||||
void session?.abort().catch(() => undefined);
|
||||
};
|
||||
const onCancellation = (): void => terminate('cancellation');
|
||||
|
||||
if (request.signal.aborted) {
|
||||
executorLog('info', { ...logContext, event: 'finished', outcome: 'cancelled' });
|
||||
throw cancellationError(request.signal);
|
||||
}
|
||||
request.signal.addEventListener('abort', onCancellation, { once: true });
|
||||
timeout = setTimeout(() => terminate('timeout'), timeoutMs);
|
||||
|
||||
try {
|
||||
let selection: ModelSelection;
|
||||
try {
|
||||
selection = await raceWithAbort(this.host.resolve('medium'), controller.signal);
|
||||
} catch (error) {
|
||||
if (termination === 'cancellation') throw cancellationError(request.signal);
|
||||
if (termination === 'timeout') throw this.timeoutError(zeroUsage(), 0);
|
||||
const failure = classifyModelFailure(this.host, error);
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'MODEL_SELECTION_FAILURE',
|
||||
message: providerFailureSentence(failure),
|
||||
retryable: failure.retryable,
|
||||
failureKind: 'model',
|
||||
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
|
||||
});
|
||||
}
|
||||
|
||||
const sourceTools = await raceWithAbort(
|
||||
createTaskFormationSourceTools({ cwd: request.cwd, deniedPaths: request.deniedPaths }),
|
||||
controller.signal,
|
||||
);
|
||||
const customTools = [...sourceTools, request.submitTool.tool];
|
||||
const toolNames = customTools.map((tool) => tool.name);
|
||||
if (!hasExactToolSet(toolNames)) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'TOOL_POLICY_MISMATCH',
|
||||
message: 'Task-formation source tool policy does not match the exact allowlist.',
|
||||
retryable: false,
|
||||
failureKind: 'confinement',
|
||||
});
|
||||
}
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
const settingsManager = SettingsManager.inMemory({
|
||||
retry: PI_RETRY_SETTINGS,
|
||||
compaction: { enabled: true },
|
||||
});
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: request.cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
systemPrompt: `${request.systemPrompt}${request.submitTool.directive ?? ''}`,
|
||||
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);
|
||||
lateSession.dispose();
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
await session.abort().catch(() => undefined);
|
||||
} else {
|
||||
controller.signal.addEventListener('abort', () => void session?.abort().catch(() => undefined), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
const registeredTools = session.getAllTools().map((tool) => tool.name);
|
||||
if (!hasExactToolSet(registeredTools)) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'LIVE_TOOL_POLICY_MISMATCH',
|
||||
message: 'The live task-formation session registered a tool outside the exact allowlist.',
|
||||
retryable: false,
|
||||
failureKind: 'confinement',
|
||||
});
|
||||
}
|
||||
|
||||
executorLog('info', {
|
||||
...logContext,
|
||||
event: 'started',
|
||||
provider: selection.providerId,
|
||||
model: selection.modelId,
|
||||
tools: registeredTools,
|
||||
resources: { context: false, extensions: false, prompts: false, skills: false },
|
||||
});
|
||||
|
||||
let pendingProviderError: unknown;
|
||||
unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
||||
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 >= maxTurns && needsAnotherTurn && request.submitTool.getAcceptedCount() === 0) {
|
||||
terminate('turn-limit');
|
||||
}
|
||||
});
|
||||
|
||||
let promptError: unknown;
|
||||
requestStarted = true;
|
||||
try {
|
||||
await raceWithAbort(session.prompt(request.modelContext, { expandPromptTemplates: false }), controller.signal);
|
||||
} catch (error) {
|
||||
promptError = error;
|
||||
}
|
||||
|
||||
const outcome: SessionOutcome = {
|
||||
pendingProviderError,
|
||||
promptError,
|
||||
usage: sessionUsage(session),
|
||||
};
|
||||
const output = this.resolveOutcome(request, outcome, termination, requestStarted);
|
||||
executorLog('info', { ...logContext, event: 'finished', outcome: 'succeeded', usage: outcome.usage });
|
||||
return {
|
||||
output,
|
||||
usage: outcome.usage,
|
||||
providerId: selection.providerId,
|
||||
modelId: selection.modelId,
|
||||
modelCalls: 1,
|
||||
registeredTools: Object.freeze([...registeredTools]),
|
||||
};
|
||||
} catch (error) {
|
||||
// Termination reason wins over whatever error surfaced. A local timeout or an abort aborts the
|
||||
// in-flight provider call, so the caught error is usually that induced abort; reporting it as a
|
||||
// model failure would erase the real cause. Cancellation keeps its own identity ahead of timeout.
|
||||
if (termination === 'cancellation') {
|
||||
executorLog('info', { ...logContext, event: 'finished', outcome: 'cancelled' });
|
||||
throw cancellationError(request.signal);
|
||||
}
|
||||
|
||||
let failure: TaskFormationExecutorError;
|
||||
if (termination === 'timeout') {
|
||||
const usage = session ? sessionUsage(session) : zeroUsage();
|
||||
const modelCalls = requestStarted ? 1 : 0;
|
||||
failure = this.timeoutError(usage, modelCalls);
|
||||
} else {
|
||||
failure = this.normalizeFailure(error);
|
||||
}
|
||||
executorLog('warn', {
|
||||
...logContext,
|
||||
event: 'finished',
|
||||
outcome: 'failed',
|
||||
code: failure.code,
|
||||
failureKind: failure.failureKind,
|
||||
retryable: failure.retryable,
|
||||
...(failure.fallbackReason !== undefined && { fallbackReason: failure.fallbackReason }),
|
||||
usage: failure.usage,
|
||||
modelCalls: failure.modelCalls,
|
||||
});
|
||||
throw failure;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
request.signal.removeEventListener('abort', onCancellation);
|
||||
unsubscribe?.();
|
||||
try {
|
||||
session?.dispose();
|
||||
} catch {
|
||||
executorLog('warn', { ...logContext, event: 'cleanup-failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeFailure(error: unknown): TaskFormationExecutorError {
|
||||
if (error instanceof TaskFormationExecutorError) return error;
|
||||
if (error instanceof ConfinementError) {
|
||||
return new TaskFormationExecutorError({
|
||||
code: `CONFINEMENT_${error.code}`,
|
||||
message: error.message,
|
||||
retryable: false,
|
||||
failureKind: 'confinement',
|
||||
});
|
||||
}
|
||||
if (isTransientIoFailure(error)) {
|
||||
return new TaskFormationExecutorError({
|
||||
code: 'SESSION_INFRASTRUCTURE_FAILURE',
|
||||
message: 'Task-formation session setup encountered a retryable infrastructure failure.',
|
||||
retryable: true,
|
||||
failureKind: 'infrastructure',
|
||||
});
|
||||
}
|
||||
|
||||
const failure = classifyModelFailure(this.host, error);
|
||||
if (failure.type === 'ConfigurationError') {
|
||||
return new TaskFormationExecutorError({
|
||||
code: 'MODEL_CONFIGURATION_FAILURE',
|
||||
message: providerFailureSentence(failure),
|
||||
retryable: false,
|
||||
failureKind: 'input',
|
||||
});
|
||||
}
|
||||
return new TaskFormationExecutorError({
|
||||
code: failure.type === 'AuthenticationError' ? 'PROVIDER_AUTHENTICATION_FAILURE' : 'MODEL_SESSION_FAILURE',
|
||||
message: providerFailureSentence(failure),
|
||||
retryable: failure.retryable,
|
||||
failureKind: 'model',
|
||||
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
|
||||
});
|
||||
}
|
||||
|
||||
private timeoutError(usage: TaskFormationUsage, modelCalls: number): TaskFormationExecutorError {
|
||||
return new TaskFormationExecutorError({
|
||||
code: 'MODEL_STAGE_TIMEOUT',
|
||||
message: 'Task formation exceeded its model-stage timeout.',
|
||||
retryable: true,
|
||||
failureKind: 'model',
|
||||
fallbackReason: 'model_stage_timeout',
|
||||
usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveOutcome(
|
||||
request: TaskFormationExecutorRequest,
|
||||
outcome: SessionOutcome,
|
||||
termination: 'cancellation' | 'timeout' | 'turn-limit' | undefined,
|
||||
requestStarted: boolean,
|
||||
): unknown {
|
||||
const modelCalls = requestStarted ? 1 : 0;
|
||||
if (termination === 'cancellation') throw cancellationError(request.signal);
|
||||
if (termination === 'timeout') throw this.timeoutError(outcome.usage, modelCalls);
|
||||
if (termination === 'turn-limit') {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'TURN_LIMIT',
|
||||
message: 'Task formation exhausted its bounded model turn limit.',
|
||||
retryable: true,
|
||||
failureKind: 'model',
|
||||
fallbackReason: 'retryable_model_failure',
|
||||
usage: outcome.usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
if (request.submitTool.getAcceptedCount() > 1) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'DUPLICATE_ACCEPTED_SUBMISSION',
|
||||
message: 'Task formation accepted more than one submission.',
|
||||
retryable: true,
|
||||
failureKind: 'model',
|
||||
fallbackReason: 'retryable_model_failure',
|
||||
usage: outcome.usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
if (outcome.pendingProviderError !== undefined) {
|
||||
const failure = classifyModelFailure(this.host, outcome.pendingProviderError);
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'PROVIDER_FAILURE',
|
||||
message: providerFailureSentence(failure),
|
||||
retryable: failure.retryable,
|
||||
failureKind: 'model',
|
||||
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
|
||||
usage: outcome.usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
// A prompt error is a real failure unless exactly one submission was already accepted and the
|
||||
// error is an abort: the submit tool terminates the session, so that abort is the expected end of
|
||||
// a successful run, not a fault.
|
||||
if (
|
||||
outcome.promptError !== undefined &&
|
||||
!(request.submitTool.getAcceptedCount() === 1 && isAbortLike(outcome.promptError))
|
||||
) {
|
||||
const failure = classifyModelFailure(this.host, outcome.promptError);
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'MODEL_SESSION_FAILURE',
|
||||
message: providerFailureSentence(failure),
|
||||
retryable: failure.retryable,
|
||||
failureKind: 'model',
|
||||
...(failure.retryable && { fallbackReason: 'retryable_model_failure' }),
|
||||
usage: outcome.usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
|
||||
const output = request.submitTool.getCaptured();
|
||||
if (request.submitTool.getAcceptedCount() !== 1 || output === undefined) {
|
||||
throw new TaskFormationExecutorError({
|
||||
code: 'MISSING_ACCEPTED_SUBMISSION',
|
||||
message: 'Task formation ended without one accepted submission.',
|
||||
retryable: true,
|
||||
failureKind: 'model',
|
||||
fallbackReason: 'missing_accepted_submission',
|
||||
usage: outcome.usage,
|
||||
modelCalls,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTaskFormationExecutor(host: ModelHost = modelHost): TaskFormationExecutor {
|
||||
return new StandaloneTaskFormationExecutor(host);
|
||||
}
|
||||
|
||||
export const taskFormationExecutor: TaskFormationExecutor = createTaskFormationExecutor();
|
||||
@@ -1,20 +1,10 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Generic `task` tool — pi.dev ships no built-in Task tool, so this supplies the
|
||||
* Task-delegation surface Shannon's prompts require.
|
||||
*
|
||||
* Shannon's prompts mandate Task delegation (recon source tracer; the vuln
|
||||
* agents delegate *every* code review; the exploit agents delegate automation),
|
||||
* so this tool is required for parity, not optional. It spawns a nested pi
|
||||
* session with the parent's resolved model object (never a tier string — that
|
||||
* would route sub-agents through hardcoded IDs and leak billing), the parent's
|
||||
* resource loader, and a fixed child tool surface.
|
||||
*/
|
||||
/** Generic child-session delegation for the pi harness. */
|
||||
|
||||
import { type AssistantMessage, type Model, Type } from '@earendil-works/pi-ai';
|
||||
import {
|
||||
@@ -27,39 +17,70 @@ import {
|
||||
SettingsManager,
|
||||
type ToolDefinition,
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import { type LoggableAgentName, normalizeSemanticLabel } from '../../audit/safe-fields.js';
|
||||
import { PI_RETRY_SETTINGS } from './retry-settings.js';
|
||||
import { TraceEmitter } from './trace-emitter.js';
|
||||
|
||||
export interface TaskToolContext {
|
||||
cwd: string;
|
||||
readonly cwd: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
model: Model<any>;
|
||||
/** Parent's model/auth runtime, reused so sub-agents share its resolved credential. */
|
||||
modelRuntime: ModelRuntime;
|
||||
resourceLoader: ResourceLoader;
|
||||
cancellationSignal?: AbortSignal | undefined;
|
||||
/**
|
||||
* Reports the cost/tokens of each spawned sub-session back to the caller.
|
||||
* Sub-agents run in their own pi sessions that the parent has no reference to,
|
||||
* so without this their spend (the bulk of a whitebox run, since Shannon
|
||||
* prompts delegate the heavy work) is invisible to billing.
|
||||
*/
|
||||
onUsage?: (usage: {
|
||||
cost: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
readonly model: Model<any>;
|
||||
readonly modelRuntime: ModelRuntime;
|
||||
readonly resourceLoader: ResourceLoader;
|
||||
readonly parentAgentName: LoggableAgentName;
|
||||
readonly workflowLogPath?: string | undefined;
|
||||
readonly onDelegationStart?: ((child: string) => Promise<void>) | undefined;
|
||||
readonly cancellationSignal?: AbortSignal | undefined;
|
||||
readonly onUsage?: (usage: {
|
||||
readonly cost: number;
|
||||
readonly inputTokens: number;
|
||||
readonly outputTokens: number;
|
||||
readonly cacheReadTokens: number;
|
||||
readonly cacheWriteTokens: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// Deliberately excludes `task` (no recursive delegation, so a child cannot spawn further children)
|
||||
// and every collector/submit tool (structured output stays owned by the top-level agent session
|
||||
// that the workflow reads back). A child session gets only plain file and shell access.
|
||||
const CHILD_TOOLS = ['read', 'grep', 'find', 'ls', 'write', 'bash'];
|
||||
const CHILD_FAILURE_TEXT = '[Sub-agent task failed before completion]';
|
||||
const CHILD_CANCELLED_TEXT = '[Sub-agent task was cancelled]';
|
||||
|
||||
function textResult(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }], details: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns each child a stable, safe display identity from its description. A duplicate of a
|
||||
* live sibling's name gets a monotonic start-order suffix (`route mapper #2`); a missing or
|
||||
* unsafe description becomes `subagent N`. State is shared across one parent's task calls,
|
||||
* and the assignment block runs synchronously so parallel calls never race on it.
|
||||
*/
|
||||
// Keep the base short enough that a `#N` suffix still fits the identity validator's length
|
||||
// bound (48); a longer description falls back to `subagent N` rather than being dropped.
|
||||
const MAX_CHILD_BASE_LENGTH = 40;
|
||||
|
||||
function createChildNamer(): (description: unknown) => string {
|
||||
const namedCounts = new Map<string, number>();
|
||||
let anonymousCount = 0;
|
||||
return (description) => {
|
||||
const base = normalizeSemanticLabel(description);
|
||||
if (base === undefined || base.length > MAX_CHILD_BASE_LENGTH) {
|
||||
anonymousCount += 1;
|
||||
return `subagent ${anonymousCount}`;
|
||||
}
|
||||
const nextOrdinal = (namedCounts.get(base) ?? 0) + 1;
|
||||
namedCounts.set(base, nextOrdinal);
|
||||
return nextOrdinal === 1 ? base : `${base} #${nextOrdinal}`;
|
||||
};
|
||||
}
|
||||
|
||||
export function createTaskTool(config: TaskToolContext): ToolDefinition {
|
||||
const taskTool: ToolDefinition = defineTool({
|
||||
const nameChild = createChildNamer();
|
||||
const logPath = config.workflowLogPath;
|
||||
|
||||
return defineTool({
|
||||
name: 'task',
|
||||
label: 'Task',
|
||||
description:
|
||||
@@ -80,59 +101,82 @@ export function createTaskTool(config: TaskToolContext): ToolDefinition {
|
||||
description: Type.Optional(Type.String({ description: 'A short (3-5 word) description of the task.' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
// Assign the identity synchronously, before any await, so concurrent siblings can't race.
|
||||
const child = nameChild(params.description);
|
||||
const emitter = logPath
|
||||
? new TraceEmitter(logPath, { kind: 'child', parent: config.parentAgentName, child })
|
||||
: undefined;
|
||||
const startedAt = Date.now();
|
||||
|
||||
// The parent's emitter first writes the raw task invocation, then this delegation
|
||||
// record. Awaiting it prevents the child emitter from overtaking its lineage start.
|
||||
await config.onDelegationStart?.(child);
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
const { session: subSession } = await createAgentSession({
|
||||
cwd: config.cwd,
|
||||
agentDir,
|
||||
resourceLoader: config.resourceLoader,
|
||||
model: config.model,
|
||||
tools: CHILD_TOOLS,
|
||||
modelRuntime: config.modelRuntime,
|
||||
sessionManager: SessionManager.inMemory(config.cwd),
|
||||
settingsManager: SettingsManager.inMemory({
|
||||
retry: PI_RETRY_SETTINGS,
|
||||
compaction: { enabled: true },
|
||||
}),
|
||||
});
|
||||
let subSession: Awaited<ReturnType<typeof createAgentSession>>['session'] | undefined;
|
||||
let resultText = '';
|
||||
let subCost = 0;
|
||||
let turns = 0;
|
||||
let operations = 0;
|
||||
let failed = false;
|
||||
let fatalFailure = false;
|
||||
|
||||
const abortChildSession = (): void => {
|
||||
void subSession.abort().catch(() => {
|
||||
// Parent logger is not available inside the tool; dispose still tears
|
||||
// down the session if abort itself rejects.
|
||||
void subSession?.abort().catch(() => {
|
||||
// Dispose below still tears down the child session.
|
||||
});
|
||||
};
|
||||
const onCancellation = (): void => abortChildSession();
|
||||
if (config.cancellationSignal?.aborted) {
|
||||
abortChildSession();
|
||||
} else {
|
||||
config.cancellationSignal?.addEventListener('abort', onCancellation, { once: true });
|
||||
}
|
||||
|
||||
let resultText = '';
|
||||
let subCost = 0;
|
||||
subSession.subscribe((event) => {
|
||||
if (event.type === 'turn_end') {
|
||||
const msg = event.message as AssistantMessage | undefined;
|
||||
for (const block of msg?.content ?? []) {
|
||||
try {
|
||||
({ session: subSession } = await createAgentSession({
|
||||
cwd: config.cwd,
|
||||
agentDir,
|
||||
resourceLoader: config.resourceLoader,
|
||||
model: config.model,
|
||||
tools: CHILD_TOOLS,
|
||||
modelRuntime: config.modelRuntime,
|
||||
sessionManager: SessionManager.inMemory(config.cwd),
|
||||
settingsManager: SettingsManager.inMemory({
|
||||
retry: PI_RETRY_SETTINGS,
|
||||
compaction: { enabled: true },
|
||||
}),
|
||||
}));
|
||||
|
||||
if (config.cancellationSignal?.aborted) {
|
||||
abortChildSession();
|
||||
} else {
|
||||
config.cancellationSignal?.addEventListener('abort', onCancellation, { once: true });
|
||||
}
|
||||
|
||||
subSession.subscribe((event) => {
|
||||
if (event.type === 'tool_execution_start') {
|
||||
operations += 1;
|
||||
emitter?.toolStart(event.toolCallId, event.toolName, event.args);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'tool_execution_end') {
|
||||
emitter?.toolEnd(event.toolCallId, event.isError);
|
||||
return;
|
||||
}
|
||||
if (event.type !== 'turn_end') return;
|
||||
turns += 1;
|
||||
const message = event.message as AssistantMessage | undefined;
|
||||
for (const block of message?.content ?? []) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
resultText += (resultText ? '\n' : '') + block.text;
|
||||
}
|
||||
}
|
||||
if (msg?.usage?.cost?.total != null) subCost += msg.usage.cost.total;
|
||||
}
|
||||
});
|
||||
if (message?.usage?.cost?.total != null) subCost += message.usage.cost.total;
|
||||
});
|
||||
|
||||
let swallowedError: string | undefined;
|
||||
try {
|
||||
try {
|
||||
await subSession.prompt(params.prompt);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
resultText += `\n[Sub-agent error: ${errorMsg}]`;
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
if (subSession.state.errorMessage !== undefined) failed = true;
|
||||
|
||||
swallowedError = subSession.state.errorMessage;
|
||||
// Read stats before dispose; reconcile cost the same way the parent does.
|
||||
const subStats = subSession.getSessionStats();
|
||||
if (subStats.cost > subCost) subCost = subStats.cost;
|
||||
config.onUsage?.({
|
||||
@@ -142,18 +186,37 @@ export function createTaskTool(config: TaskToolContext): ToolDefinition {
|
||||
cacheReadTokens: subStats.tokens.cacheRead,
|
||||
cacheWriteTokens: subStats.tokens.cacheWrite,
|
||||
});
|
||||
} catch {
|
||||
fatalFailure = true;
|
||||
} finally {
|
||||
config.cancellationSignal?.removeEventListener('abort', onCancellation);
|
||||
subSession.dispose();
|
||||
subSession?.dispose();
|
||||
}
|
||||
|
||||
if (swallowedError && !resultText.includes(swallowedError)) {
|
||||
resultText += `\n[Sub-agent error: ${swallowedError}]`;
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (config.cancellationSignal?.aborted) {
|
||||
emitter?.sessionFailure('CANCELLED', durationMs);
|
||||
await emitter?.flush();
|
||||
return textResult(CHILD_CANCELLED_TEXT);
|
||||
}
|
||||
// `fatalFailure` means the child session itself never came up (createAgentSession threw), so
|
||||
// there is no session result to hand back, and this rethrows, which pi surfaces to the parent
|
||||
// as a failed tool call. `failed` means the session ran but ended in error; that gets a normal
|
||||
// text result instead, so the parent model sees the failure and can decide how to proceed.
|
||||
if (fatalFailure) {
|
||||
emitter?.sessionFailure('CHILD_TASK_FAILED', durationMs);
|
||||
await emitter?.flush();
|
||||
throw new Error(CHILD_FAILURE_TEXT);
|
||||
}
|
||||
if (failed) {
|
||||
emitter?.sessionFailure('CHILD_TASK_FAILED', durationMs);
|
||||
await emitter?.flush();
|
||||
return textResult(CHILD_FAILURE_TEXT);
|
||||
}
|
||||
|
||||
emitter?.sessionComplete(durationMs, turns, operations);
|
||||
await emitter?.flush();
|
||||
return textResult(resultText || '[Sub-agent produced no output]');
|
||||
},
|
||||
});
|
||||
|
||||
return taskTool;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Per-session trace emitter. Owns the PI `toolCallId` correlation and the ordering
|
||||
* of one agent or subagent's trace lines, then writes them through the stateless
|
||||
* `WorkflowLogger` formatter. One instance per parent agent run or per delegated
|
||||
* child session, so parallel calls never cross.
|
||||
*/
|
||||
|
||||
import { captureToolInvocation, decideToolOutcome } from '../../audit/trace.js';
|
||||
import { type ChildTaskFailureCode, type TraceActor, WorkflowLogger } from '../../audit/workflow-logger.js';
|
||||
|
||||
interface PendingCall {
|
||||
readonly tool: string;
|
||||
readonly startedAt: number;
|
||||
readonly count?: (() => number | undefined) | undefined;
|
||||
}
|
||||
|
||||
export class TraceEmitter {
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private readonly pending = new Map<string, PendingCall>();
|
||||
|
||||
constructor(
|
||||
private readonly logPath: string,
|
||||
private readonly actor: TraceActor,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Snapshot and log a tool call's complete arguments. `count`, when supplied, is an
|
||||
* accessor for that specific collector's existing submitted-array count outcome.
|
||||
*/
|
||||
toolStart(toolCallId: string, toolName: string, args: unknown, count?: () => number | undefined): void {
|
||||
const invocation = captureToolInvocation(toolName, args);
|
||||
this.pending.set(toolCallId, { tool: toolName, startedAt: this.now(), count });
|
||||
if (invocation !== undefined) this.enqueue(() => WorkflowLogger.logToolCall(this.logPath, this.actor, invocation));
|
||||
}
|
||||
|
||||
toolEnd(toolCallId: string, isError: boolean): void {
|
||||
const call = this.pending.get(toolCallId);
|
||||
if (call === undefined) return;
|
||||
this.pending.delete(toolCallId);
|
||||
const outcome = decideToolOutcome(call.tool, isError, this.now() - call.startedAt, call.count?.());
|
||||
if (outcome !== undefined) this.enqueue(() => WorkflowLogger.logToolOutcome(this.logPath, this.actor, outcome));
|
||||
}
|
||||
|
||||
/** Queue and await delegation on the parent emitter before a child session can start. */
|
||||
delegationStart(child: string): Promise<void> {
|
||||
const actor = this.actor;
|
||||
if (actor.kind !== 'agent') return Promise.resolve();
|
||||
return this.enqueue(() => WorkflowLogger.logDelegationStart(this.logPath, actor.agent, child));
|
||||
}
|
||||
|
||||
sessionComplete(durationMs: number, turns: number, operations: number): void {
|
||||
this.enqueue(() => WorkflowLogger.logSessionComplete(this.logPath, this.actor, durationMs, turns, operations));
|
||||
}
|
||||
|
||||
sessionFailure(code: ChildTaskFailureCode, durationMs: number): void {
|
||||
this.enqueue(() => WorkflowLogger.logSessionFailure(this.logPath, this.actor, code, durationMs));
|
||||
}
|
||||
|
||||
// Chained regardless of outcome (`then(operation, operation)`) so one write's rejection cannot
|
||||
// stall the ones queued after it, and the trailing catch swallows the failure entirely: a trace
|
||||
// line is diagnostic only, so losing one must never surface as, or block, the agent's own result.
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
this.queue = this.queue.then(operation, operation).catch(() => undefined);
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
/** Await all queued writes so a caller can order a terminal line after them. */
|
||||
async flush(): Promise<void> {
|
||||
await this.queue;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,35 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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 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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -9,6 +9,13 @@
|
||||
import { ProgressIndicator } from '../progress-indicator.js';
|
||||
import { extractAgentType } from '../utils/formatting.js';
|
||||
|
||||
/**
|
||||
* `useCleanOutput` marks the phases that use the friendly "Running X..."
|
||||
* spinner plus a one-line completion message (pre-recon, recon, report, and
|
||||
* the vuln/exploit agents) as opposed to the verbose turn-by-turn fallback
|
||||
* formatting used elsewhere. `createProgressManager` reads it to decide
|
||||
* between a real spinner and the silent null one.
|
||||
*/
|
||||
export interface ProgressContext {
|
||||
description: string;
|
||||
useCleanOutput: boolean;
|
||||
@@ -62,7 +69,8 @@ class NullProgressManager implements ProgressManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns no-op when disabled
|
||||
// Returns no-op when disabled. `disableLoader` lets a caller force the silent manager regardless
|
||||
// of useCleanOutput, for a context where an animated spinner would be unwanted no matter the phase.
|
||||
export function createProgressManager(context: ProgressContext, disableLoader: boolean): ProgressManager {
|
||||
if (!context.useCleanOutput || disableLoader) {
|
||||
return new NullProgressManager();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
// 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
|
||||
@@ -16,6 +16,9 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
|
||||
import { type Static, type TObject, Type } from 'typebox';
|
||||
import { stringEnum } from '../collectors/schema.js';
|
||||
import type { AgentName } from '../types/agents.js';
|
||||
import type { VulnClass } from '../types/config.js';
|
||||
import type { ReconciliationClass } from '../types/reconciliation.js';
|
||||
import { isProducerId, REF_PREFIX } from './reconciliation/refs.js';
|
||||
import type { CapturedSubmitTool } from './submit-tool.js';
|
||||
|
||||
const ANALYSIS_NOTES_DESCRIPTION = 'Plain context for defenders (caveats, scope, what is at risk). Not attack steps.';
|
||||
@@ -24,6 +27,18 @@ function optStr(description?: string) {
|
||||
return Type.Optional(Type.String(description === undefined ? {} : { description }));
|
||||
}
|
||||
|
||||
const analysisCodeLocationSchema = Type.Object({
|
||||
file: Type.String({ description: 'Repository-relative path, no leading slash.' }),
|
||||
start_line: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
end_line: Type.Optional(Type.Integer({ minimum: 1, description: 'Set when the flaw spans a range.' })),
|
||||
role: stringEnum(['sink', 'source', 'guard'], {
|
||||
description:
|
||||
'sink where the flaw manifests, source where untrusted input enters, guard for a check ' +
|
||||
'that is missing or misplaced.',
|
||||
}),
|
||||
symbol: Type.Optional(Type.String({ description: 'Enclosing function or method, named as written in the code.' })),
|
||||
});
|
||||
|
||||
/**
|
||||
* Base fields shared by every queue entry. `notes` gains guidance in analysis mode.
|
||||
*
|
||||
@@ -39,22 +54,9 @@ function baseFields(exploit: boolean) {
|
||||
description: 'Confidence that this is a real, reachable vulnerability.',
|
||||
}),
|
||||
code_locations: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
file: Type.String({ description: 'Repository-relative path, no leading slash.' }),
|
||||
start_line: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
end_line: Type.Optional(Type.Integer({ minimum: 1, description: 'Set when the flaw spans a range.' })),
|
||||
role: stringEnum(['sink', 'source', 'guard'], {
|
||||
description:
|
||||
'sink where the flaw manifests, source where untrusted input enters, guard for a check ' +
|
||||
'that is missing or misplaced.',
|
||||
}),
|
||||
symbol: Type.Optional(
|
||||
Type.String({ description: 'Enclosing function or method, named as written in the code.' }),
|
||||
),
|
||||
}),
|
||||
{ description: 'Every code site this finding touches, sink first.' },
|
||||
),
|
||||
Type.Array(analysisCodeLocationSchema, {
|
||||
description: 'Every code site this finding touches, sink first.',
|
||||
}),
|
||||
),
|
||||
notes: exploit ? optStr() : optStr(ANALYSIS_NOTES_DESCRIPTION),
|
||||
};
|
||||
@@ -102,6 +104,17 @@ const ssrfFields = {
|
||||
suggested_exploit_technique: optStr(),
|
||||
};
|
||||
|
||||
const miscellaneousFields = {
|
||||
cwe: optStr(),
|
||||
source_endpoint: optStr(),
|
||||
vulnerable_code_location: optStr(),
|
||||
missing_defense: optStr(),
|
||||
observable_signal: optStr(),
|
||||
exploitation_hypothesis: optStr(),
|
||||
suggested_exploit_technique: optStr(),
|
||||
proof_criterion: optStr(),
|
||||
};
|
||||
|
||||
const authzFields = {
|
||||
endpoint: optStr(),
|
||||
vulnerable_code_location: optStr(),
|
||||
@@ -119,14 +132,44 @@ const xssEntry = () => Type.Object({ ...baseFields(true), ...xssFields });
|
||||
const authEntry = () => Type.Object({ ...baseFields(true), ...authFields });
|
||||
const ssrfEntry = () => Type.Object({ ...baseFields(true), ...ssrfFields });
|
||||
const authzEntry = () => Type.Object({ ...baseFields(true), ...authzFields });
|
||||
const miscellaneousEntry = () => Type.Object({ ...baseFields(true), ...miscellaneousFields });
|
||||
|
||||
export type QueueCodeLocation = NonNullable<Static<ReturnType<typeof injectionEntry>>['code_locations']>[number];
|
||||
export type AnalysisCodeLocation = Static<typeof analysisCodeLocationSchema>;
|
||||
|
||||
/** Queue-specific name retained for the existing analysis-location report join. */
|
||||
export type QueueCodeLocation = AnalysisCodeLocation;
|
||||
|
||||
export type InjectionFinding = Static<ReturnType<typeof injectionEntry>>;
|
||||
export type XssFinding = Static<ReturnType<typeof xssEntry>>;
|
||||
export type AuthFinding = Static<ReturnType<typeof authEntry>>;
|
||||
export type SsrfFinding = Static<ReturnType<typeof ssrfEntry>>;
|
||||
export type AuthzFinding = Static<ReturnType<typeof authzEntry>>;
|
||||
export type MiscellaneousFinding = Static<ReturnType<typeof miscellaneousEntry>>;
|
||||
|
||||
// The exact field names each class's queue entry carries. Reconciliation reads this to know which
|
||||
// keys a class produces, so it must list the same base and per-class fields the schemas above build.
|
||||
export const QUEUE_ENTRY_FIELD_NAMES: Readonly<Record<ReconciliationClass, readonly string[]>> = Object.freeze({
|
||||
injection: [...Object.keys(baseFields(true)), ...Object.keys(injectionFields)],
|
||||
xss: [...Object.keys(baseFields(true)), ...Object.keys(xssFields)],
|
||||
auth: [...Object.keys(baseFields(true)), ...Object.keys(authFields)],
|
||||
authz: [...Object.keys(baseFields(true)), ...Object.keys(authzFields)],
|
||||
ssrf: [...Object.keys(baseFields(true)), ...Object.keys(ssrfFields)],
|
||||
miscellaneous: [...Object.keys(baseFields(true)), ...Object.keys(miscellaneousFields)],
|
||||
});
|
||||
|
||||
const ENTRY_SCHEMAS: Readonly<Record<ReconciliationClass, TObject>> = Object.freeze({
|
||||
injection: injectionEntry(),
|
||||
xss: xssEntry(),
|
||||
auth: authEntry(),
|
||||
authz: authzEntry(),
|
||||
ssrf: ssrfEntry(),
|
||||
miscellaneous: miscellaneousEntry(),
|
||||
});
|
||||
|
||||
/** The complete queue-entry schema for one internal class. */
|
||||
export function classEntrySchema(vulnClass: ReconciliationClass): TObject {
|
||||
return ENTRY_SCHEMAS[vulnClass];
|
||||
}
|
||||
|
||||
const PER_TYPE_FIELDS: Partial<Record<AgentName, Record<string, ReturnType<typeof optStr>>>> = {
|
||||
'injection-vuln': injectionFields,
|
||||
@@ -144,12 +187,102 @@ const VULN_AGENT_QUEUE_FILENAMES: Partial<Record<AgentName, string>> = {
|
||||
'authz-vuln': 'authz_exploitation_queue.json',
|
||||
};
|
||||
|
||||
/** Build the TypeBox submit-tool parameters for a vuln agent, or undefined for non-vuln agents. */
|
||||
const VULN_AGENT_CLASSES: Partial<Record<AgentName, VulnClass>> = {
|
||||
'injection-vuln': 'injection',
|
||||
'xss-vuln': 'xss',
|
||||
'auth-vuln': 'auth',
|
||||
'authz-vuln': 'authz',
|
||||
'ssrf-vuln': 'ssrf',
|
||||
};
|
||||
|
||||
function producerIdFormat(vulnClass: VulnClass): string {
|
||||
return `${REF_PREFIX[vulnClass]}-VULN-NN`;
|
||||
}
|
||||
|
||||
function outOfNamespaceIds(vulnerabilities: readonly unknown[], vulnClass: VulnClass): string[] {
|
||||
const rejected: string[] = [];
|
||||
for (const entry of vulnerabilities) {
|
||||
if (entry === null || typeof entry !== 'object') continue;
|
||||
const id = (entry as { ID?: unknown }).ID;
|
||||
if (typeof id !== 'string' || !isProducerId(id, vulnClass, 'VULN')) {
|
||||
rejected.push(typeof id === 'string' ? id : String(id));
|
||||
}
|
||||
}
|
||||
return rejected;
|
||||
}
|
||||
|
||||
const ID_PREVIEW_LIMIT = 6;
|
||||
|
||||
function previewIds(ids: readonly string[]): string {
|
||||
const shown = ids.slice(0, ID_PREVIEW_LIMIT).join(', ');
|
||||
const remainder = ids.length - ID_PREVIEW_LIMIT;
|
||||
return remainder > 0 ? `${shown} (+${remainder} more)` : shown;
|
||||
}
|
||||
|
||||
// A rejected submission is returned as a retryable tool error rather than thrown: that hands the
|
||||
// message back to the model to correct within the same session instead of failing the whole agent.
|
||||
// `terminate` is deliberately left unset so the session survives to receive the corrected call.
|
||||
function retryableRejection(message: string) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({ status: 'error', errorType: 'ValidationError', retryable: true, message }, null, 2),
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function idNamespaceRejection(vulnClass: VulnClass, rejected: readonly string[]) {
|
||||
const message =
|
||||
`Every entry ID must be ${producerIdFormat(vulnClass)} ` +
|
||||
`(for example ${REF_PREFIX[vulnClass]}-VULN-01). Outside that namespace: ${previewIds(rejected)}.`;
|
||||
return retryableRejection(message);
|
||||
}
|
||||
|
||||
// Exact-string repeats, the same comparison reconciliation makes when it re-reads the committed
|
||||
// queue. Each offending ID is named once, in the order it first repeats.
|
||||
function repeatedProducerIds(vulnerabilities: readonly unknown[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const repeated: string[] = [];
|
||||
for (const entry of vulnerabilities) {
|
||||
if (entry === null || typeof entry !== 'object') continue;
|
||||
const id = (entry as { ID?: unknown }).ID;
|
||||
if (typeof id !== 'string') continue;
|
||||
if (seen.has(id) && !repeated.includes(id)) repeated.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
return repeated;
|
||||
}
|
||||
|
||||
function duplicateIdRejection(vulnClass: VulnClass, repeated: readonly string[]) {
|
||||
const message =
|
||||
`Each entry needs its own ${producerIdFormat(vulnClass)} ID; these appear more than once: ` +
|
||||
`${previewIds(repeated)}. Renumber the repeated entries — or drop the ones that describe the same ` +
|
||||
'vulnerability — and call submit_exploitation_queue again.';
|
||||
return retryableRejection(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the TypeBox submit-tool parameters for a vuln agent, or undefined for non-vuln agents.
|
||||
*
|
||||
* The `ID` this schema requires (`INJ-VULN-01` and so on) is an internal producer token, meant
|
||||
* only to let reconciliation join a finding back to the agent and class that raised it. It is not
|
||||
* the identifier a downstream exploit agent should ever see; reconciliation is responsible for
|
||||
* translating it into the exploitation-task identity the published queue carries instead.
|
||||
*/
|
||||
function queueSchema(agentName: AgentName, exploit: boolean): TObject | undefined {
|
||||
const extra = PER_TYPE_FIELDS[agentName];
|
||||
if (!extra) return undefined;
|
||||
const vulnClass = VULN_AGENT_CLASSES[agentName];
|
||||
if (!extra || !vulnClass) return undefined;
|
||||
const idField = Type.String({
|
||||
description:
|
||||
`Producer identifier formatted ${producerIdFormat(vulnClass)} ` +
|
||||
`(for example ${REF_PREFIX[vulnClass]}-VULN-01).`,
|
||||
});
|
||||
return Type.Object({
|
||||
vulnerabilities: Type.Array(Type.Object({ ...baseFields(exploit), ...extra })),
|
||||
vulnerabilities: Type.Array(Type.Object({ ...baseFields(exploit), ID: idField, ...extra })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +294,8 @@ export function getQueueFilename(agentName: AgentName): string | undefined {
|
||||
/** Build the pi submit tool that captures the exploitation queue for vuln agents. */
|
||||
export function createQueueSubmitTool(agentName: AgentName, exploit = true): CapturedSubmitTool | undefined {
|
||||
const schema = queueSchema(agentName, exploit);
|
||||
if (!schema) return undefined;
|
||||
const vulnClass = VULN_AGENT_CLASSES[agentName];
|
||||
if (!schema || !vulnClass) return undefined;
|
||||
|
||||
let captured: unknown | undefined;
|
||||
return {
|
||||
@@ -174,21 +308,39 @@ export function createQueueSubmitTool(agentName: AgentName, exploit = true): Cap
|
||||
promptGuidelines: [
|
||||
'You MUST call submit_exploitation_queue exactly once as your final action.',
|
||||
'Include every analyzed finding in the vulnerabilities array.',
|
||||
`Give every entry an ID in the ${producerIdFormat(vulnClass)} namespace.`,
|
||||
],
|
||||
parameters: schema,
|
||||
async execute(_toolCallId, params) {
|
||||
const vulnerabilities = Array.isArray((params as { vulnerabilities?: unknown }).vulnerabilities)
|
||||
? (params as { vulnerabilities: unknown[] }).vulnerabilities
|
||||
: [];
|
||||
// Every entry ID must sit in this class's producer namespace, and no two entries may share
|
||||
// one, so reconciliation refs stay unique both across classes and within this queue. Both
|
||||
// rules are the ones reconciliation applies to the committed queue; enforcing them here
|
||||
// turns a permanent post-commit failure into an in-session correction. Nothing is captured,
|
||||
// written, or committed until every ID passes.
|
||||
const rejected = outOfNamespaceIds(vulnerabilities, vulnClass);
|
||||
if (rejected.length > 0) {
|
||||
return idNamespaceRejection(vulnClass, rejected);
|
||||
}
|
||||
const repeated = repeatedProducerIds(vulnerabilities);
|
||||
if (repeated.length > 0) {
|
||||
return duplicateIdRejection(vulnClass, repeated);
|
||||
}
|
||||
captured = params;
|
||||
const count = Array.isArray((params as { vulnerabilities?: unknown }).vulnerabilities)
|
||||
? (params as { vulnerabilities: unknown[] }).vulnerabilities.length
|
||||
: 0;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Recorded ${count} findings.` }],
|
||||
content: [{ type: 'text' as const, text: `Recorded ${vulnerabilities.length} findings.` }],
|
||||
details: params,
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getCaptured: () => captured,
|
||||
safeCount: () => {
|
||||
const vulnerabilities = (captured as { vulnerabilities?: unknown } | undefined)?.vulnerabilities;
|
||||
return Array.isArray(vulnerabilities) ? vulnerabilities.length : undefined;
|
||||
},
|
||||
directive:
|
||||
'\n\nYou MUST call the submit_exploitation_queue tool exactly once as your final action ' +
|
||||
'to deliver your structured exploitation queue. Do not output JSON as text. Fill every required parameter.',
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
/** Content-addressed, no-replace storage for reconciliation intermediates. */
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { Stats } from 'node:fs';
|
||||
import { link, lstat, mkdir, open, readFile, realpath, unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WORKSPACES_DIR } from '../../paths.js';
|
||||
import { ALL_RECONCILIATION_CLASSES, type ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import type { ArtifactInputDigest, ArtifactKind, ArtifactRef } from './contracts.js';
|
||||
import { RECONCILIATION_SCHEMA_VERSION } from './schema-version.js';
|
||||
import type { ArtifactBodyMap } from './stage-contracts.js';
|
||||
|
||||
export { RECONCILIATION_SCHEMA_VERSION };
|
||||
|
||||
const KIND_SEQUENCE: Readonly<Record<ArtifactKind, string>> = Object.freeze({
|
||||
'producer-observations': '00',
|
||||
'supplemental-observations': '01',
|
||||
'task-formation': '02',
|
||||
'fixed-tasks': '03',
|
||||
});
|
||||
|
||||
const ARTIFACT_KINDS = Object.freeze(Object.keys(KIND_SEQUENCE) as ArtifactKind[]);
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type ReconciliationFailureType =
|
||||
| 'ReconciliationArtifactNotFound'
|
||||
| 'ReconciliationIoError'
|
||||
| 'ArtifactIntegrityError'
|
||||
| 'PublicationConflict'
|
||||
| 'SastEnrichmentInputError'
|
||||
| 'SastEnrichmentModelError';
|
||||
|
||||
/** Typed reconciliation failure normalized by the later Temporal activity boundary. */
|
||||
export class ReconciliationError extends Error {
|
||||
readonly retryable: boolean;
|
||||
readonly failureType: ReconciliationFailureType;
|
||||
|
||||
// The default failure type follows `retryable`: a retryable error reads as transient I/O,
|
||||
// a non-retryable one as an integrity violation. Temporal keeps retrying the former and
|
||||
// fails fast on the latter, so the two must stay aligned.
|
||||
constructor(
|
||||
message: string,
|
||||
retryable: boolean,
|
||||
failureType: ReconciliationFailureType = retryable ? 'ReconciliationIoError' : 'ArtifactIntegrityError',
|
||||
) {
|
||||
super(message);
|
||||
this.name = failureType;
|
||||
this.retryable = retryable;
|
||||
this.failureType = failureType;
|
||||
}
|
||||
}
|
||||
|
||||
export class ReconciliationArtifactNotFoundError extends ReconciliationError {
|
||||
constructor(message: string) {
|
||||
super(message, true, 'ReconciliationArtifactNotFound');
|
||||
}
|
||||
}
|
||||
|
||||
export class ReconciliationIoError extends ReconciliationError {
|
||||
constructor(message: string) {
|
||||
super(message, true, 'ReconciliationIoError');
|
||||
}
|
||||
}
|
||||
|
||||
export class ArtifactIntegrityError extends ReconciliationError {
|
||||
constructor(message: string) {
|
||||
super(message, false, 'ArtifactIntegrityError');
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicationConflictError extends ReconciliationError {
|
||||
constructor(message: string) {
|
||||
super(message, false, 'PublicationConflict');
|
||||
}
|
||||
}
|
||||
|
||||
interface ArtifactEnvelope {
|
||||
artifactKind: ArtifactKind;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
schemaVersion: 1;
|
||||
inputs: ArtifactInputDigest[];
|
||||
counts: Record<string, number>;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
function validateSessionId(sessionId: string): void {
|
||||
const valid =
|
||||
sessionId.length > 0 &&
|
||||
sessionId !== '.' &&
|
||||
sessionId !== '..' &&
|
||||
!sessionId.includes('/') &&
|
||||
!sessionId.includes('\\') &&
|
||||
!sessionId.includes('\0');
|
||||
if (!valid) {
|
||||
throw new ArtifactIntegrityError('Invalid reconciliation session identifier');
|
||||
}
|
||||
}
|
||||
|
||||
function validateClass(vulnerabilityClass: ReconciliationClass): void {
|
||||
if (!ALL_RECONCILIATION_CLASSES.includes(vulnerabilityClass)) {
|
||||
throw new ArtifactIntegrityError('Invalid reconciliation class');
|
||||
}
|
||||
}
|
||||
|
||||
function validateDigest(sha256: string, label: string): void {
|
||||
if (!SHA256_PATTERN.test(sha256)) {
|
||||
throw new ArtifactIntegrityError(`${label} is not a lowercase SHA-256 digest`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateInputs(inputs: readonly ArtifactInputDigest[]): void {
|
||||
if (!Array.isArray(inputs)) {
|
||||
throw new ArtifactIntegrityError('Artifact lineage is not an array');
|
||||
}
|
||||
for (const input of inputs) {
|
||||
if (!isRecord(input) || !ARTIFACT_KINDS.includes(input.artifactKind as ArtifactKind)) {
|
||||
throw new ArtifactIntegrityError('Artifact lineage contains an invalid kind');
|
||||
}
|
||||
validateDigest(input.sha256 as string, 'Artifact lineage digest');
|
||||
if (Object.keys(input).sort().join(',') !== 'artifactKind,sha256') {
|
||||
throw new ArtifactIntegrityError('Artifact lineage contains unexpected metadata');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateCounts(counts: Record<string, number>): void {
|
||||
if (!isRecord(counts)) {
|
||||
throw new ArtifactIntegrityError('Artifact counts are not an object');
|
||||
}
|
||||
for (const [name, value] of Object.entries(counts)) {
|
||||
if (name.length === 0 || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new ArtifactIntegrityError('Artifact counts contain an invalid entry');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sha256Hex(bytes: Buffer): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function artifactFilename(kind: ArtifactKind, sha256: string): string {
|
||||
return `${KIND_SEQUENCE[kind]}-${kind}-${sha256}.json`;
|
||||
}
|
||||
|
||||
function serializeEnvelope(envelope: ArtifactEnvelope): Buffer {
|
||||
try {
|
||||
return Buffer.from(JSON.stringify(envelope), 'utf8');
|
||||
} catch {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact body is not JSON serializable');
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable root for one class, independent of customer output destinations. */
|
||||
export function reconciliationDir(
|
||||
sessionId: string,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
workspacesDir: string = WORKSPACES_DIR,
|
||||
): string {
|
||||
validateSessionId(sessionId);
|
||||
validateClass(vulnerabilityClass);
|
||||
return path.resolve(workspacesDir, sessionId, '.shannon', 'reconciliation', vulnerabilityClass);
|
||||
}
|
||||
|
||||
async function ensureDirectory(parent: string, segment: string): Promise<string> {
|
||||
const next = path.join(parent, segment);
|
||||
try {
|
||||
await mkdir(next);
|
||||
} catch (error) {
|
||||
if (!isErrno(error, 'EEXIST')) {
|
||||
throw new ReconciliationIoError('Unable to create reconciliation artifact directory');
|
||||
}
|
||||
}
|
||||
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await lstat(next);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to inspect reconciliation artifact directory');
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact root contains a symlink or non-directory');
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// `ensureArtifactRoot` (write path) and `resolveArtifactRoot` (read path) are kept as separate
|
||||
// functions rather than one with a "create if missing" flag: a read for a session/class that never
|
||||
// wrote anything must fail as not-found, not silently materialize an empty directory chain that
|
||||
// would then make an absent artifact look like a not-yet-written one.
|
||||
async function ensureArtifactRoot(
|
||||
sessionId: string,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
workspacesDir: string,
|
||||
): Promise<string> {
|
||||
validateSessionId(sessionId);
|
||||
validateClass(vulnerabilityClass);
|
||||
try {
|
||||
await mkdir(workspacesDir, { recursive: true });
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to create the reconciliation workspace root');
|
||||
}
|
||||
|
||||
let realWorkspaces: string;
|
||||
try {
|
||||
realWorkspaces = await realpath(workspacesDir);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to resolve the reconciliation workspace root');
|
||||
}
|
||||
|
||||
let current = realWorkspaces;
|
||||
for (const segment of [sessionId, '.shannon', 'reconciliation', vulnerabilityClass]) {
|
||||
current = await ensureDirectory(current, segment);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
async function resolveArtifactRoot(
|
||||
sessionId: string,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
workspacesDir: string,
|
||||
): Promise<string> {
|
||||
validateSessionId(sessionId);
|
||||
validateClass(vulnerabilityClass);
|
||||
|
||||
let current: string;
|
||||
try {
|
||||
current = await realpath(workspacesDir);
|
||||
} catch {
|
||||
throw new ReconciliationArtifactNotFoundError('Reconciliation workspace root is not visible');
|
||||
}
|
||||
|
||||
for (const segment of [sessionId, '.shannon', 'reconciliation', vulnerabilityClass]) {
|
||||
const next = path.join(current, segment);
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await lstat(next);
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) {
|
||||
throw new ReconciliationArtifactNotFoundError('Reconciliation artifact root is not visible');
|
||||
}
|
||||
throw new ReconciliationIoError('Unable to inspect reconciliation artifact root');
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact root contains a symlink or non-directory');
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
async function writeDurableTemporaryFile(tempPath: string, bytes: Buffer): Promise<void> {
|
||||
try {
|
||||
// `wx` fails if the attempt-unique temp path already exists, and the fsync forces the bytes
|
||||
// to disk before the later `link` publishes them. Publishing an unsynced file would let a
|
||||
// crash leave a linked-but-empty artifact that its digest no longer matches.
|
||||
const handle = await open(tempPath, 'wx');
|
||||
try {
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
await unlink(tempPath).catch(() => undefined);
|
||||
throw new ReconciliationIoError('Unable to durably create an attempt-unique reconciliation artifact');
|
||||
}
|
||||
}
|
||||
|
||||
// Some filesystems reject fsync on a directory handle. Those codes mean the durability barrier
|
||||
// is unavailable, not that publication failed, so the caller treats them as success.
|
||||
function directorySyncIsUnsupported(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code === 'EINVAL' || code === 'ENOTSUP' || code === 'EOPNOTSUPP' || code === 'EBADF' || code === 'EISDIR';
|
||||
}
|
||||
|
||||
async function syncPublishedDirectory(directory: string): Promise<void> {
|
||||
try {
|
||||
const handle = await open(directory, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (directorySyncIsUnsupported(error)) return;
|
||||
throw new ReconciliationIoError('Unable to make the reconciliation artifact directory durable');
|
||||
}
|
||||
}
|
||||
|
||||
export type WriteArtifactArgs<TKind extends ArtifactKind = ArtifactKind> = {
|
||||
[K in TKind]: {
|
||||
sessionId: string;
|
||||
workspacesDir?: string;
|
||||
artifactKind: K;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
body: ArtifactBodyMap[K];
|
||||
inputs: ArtifactInputDigest[];
|
||||
counts: Record<string, number>;
|
||||
};
|
||||
}[TKind];
|
||||
|
||||
/** Serialize and publish one whole-envelope artifact without replacing an existing path. */
|
||||
export async function writeArtifact<TKind extends ArtifactKind>(
|
||||
args: WriteArtifactArgs<TKind>,
|
||||
): Promise<ArtifactRef<TKind>> {
|
||||
validateInputs(args.inputs);
|
||||
validateCounts(args.counts);
|
||||
|
||||
const envelope: ArtifactEnvelope = {
|
||||
artifactKind: args.artifactKind,
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
schemaVersion: RECONCILIATION_SCHEMA_VERSION,
|
||||
inputs: args.inputs,
|
||||
counts: args.counts,
|
||||
body: args.body,
|
||||
};
|
||||
const bytes = serializeEnvelope(envelope);
|
||||
const sha256 = sha256Hex(bytes);
|
||||
const root = await ensureArtifactRoot(args.sessionId, args.vulnerabilityClass, args.workspacesDir ?? WORKSPACES_DIR);
|
||||
const filename = artifactFilename(args.artifactKind, sha256);
|
||||
const finalPath = path.join(root, filename);
|
||||
const tempPath = path.join(root, `.${filename}.tmp-${randomBytes(12).toString('hex')}`);
|
||||
|
||||
await writeDurableTemporaryFile(tempPath, bytes);
|
||||
|
||||
// The final path is content-addressed by digest, so an EEXIST link means a prior attempt already
|
||||
// published these exact bytes. Identical bytes are adopted as success (idempotent republish);
|
||||
// different bytes at the same digest path can only be corruption or a hash collision, so they
|
||||
// conflict. This is what makes a retried write after a lost acknowledgement safe.
|
||||
let failure: unknown;
|
||||
try {
|
||||
await link(tempPath, finalPath);
|
||||
} catch (error) {
|
||||
if (!isErrno(error, 'EEXIST')) {
|
||||
failure = new ReconciliationIoError('Unable to publish reconciliation artifact');
|
||||
} else {
|
||||
try {
|
||||
const stat = await lstat(finalPath);
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
failure = new ArtifactIntegrityError('Existing reconciliation artifact is not a regular file');
|
||||
} else {
|
||||
const existing = await readFile(finalPath);
|
||||
if (!existing.equals(bytes)) {
|
||||
failure = new PublicationConflictError('Existing reconciliation artifact has different exact bytes');
|
||||
}
|
||||
}
|
||||
} catch (readError) {
|
||||
failure =
|
||||
readError instanceof ReconciliationError
|
||||
? readError
|
||||
: new ReconciliationIoError('Unable to verify existing reconciliation artifact');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failure === undefined) {
|
||||
try {
|
||||
await syncPublishedDirectory(root);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the attempt-local temp link last. A cleanup failure is only reported when nothing
|
||||
// earlier failed, so temp-file noise never masks a real publication or conflict error.
|
||||
try {
|
||||
await unlink(tempPath);
|
||||
} catch (error) {
|
||||
if (!isErrno(error, 'ENOENT') && failure === undefined) {
|
||||
failure = new ReconciliationIoError('Unable to remove attempt-local reconciliation artifact');
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
|
||||
return {
|
||||
path: finalPath,
|
||||
artifactKind: args.artifactKind,
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
schemaVersion: RECONCILIATION_SCHEMA_VERSION,
|
||||
sha256,
|
||||
inputs: args.inputs,
|
||||
counts: args.counts,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether two ordered artifact lineages contain identical kinds and digests. */
|
||||
export function artifactInputsMatch(
|
||||
first: readonly ArtifactInputDigest[],
|
||||
second: readonly ArtifactInputDigest[],
|
||||
): boolean {
|
||||
return (
|
||||
first.length === second.length &&
|
||||
first.every(
|
||||
(entry, index) => entry.artifactKind === second[index]?.artifactKind && entry.sha256 === second[index]?.sha256,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function countsMatch(first: Record<string, number>, second: Record<string, number>): boolean {
|
||||
const firstKeys = Object.keys(first).sort();
|
||||
const secondKeys = Object.keys(second).sort();
|
||||
return (
|
||||
firstKeys.length === secondKeys.length &&
|
||||
firstKeys.every((key, index) => key === secondKeys[index] && first[key] === second[key])
|
||||
);
|
||||
}
|
||||
|
||||
function parseEnvelope(bytes: Buffer): ArtifactEnvelope {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bytes.toString('utf8'));
|
||||
} catch {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact is not valid JSON');
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact envelope is not an object');
|
||||
}
|
||||
const expectedKeys = ['artifactKind', 'body', 'counts', 'inputs', 'schemaVersion', 'vulnerabilityClass'];
|
||||
if (Object.keys(parsed).sort().join(',') !== expectedKeys.join(',')) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact envelope has unexpected fields');
|
||||
}
|
||||
if (!ARTIFACT_KINDS.includes(parsed.artifactKind as ArtifactKind)) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact kind is invalid');
|
||||
}
|
||||
if (!ALL_RECONCILIATION_CLASSES.includes(parsed.vulnerabilityClass as ReconciliationClass)) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact class is invalid');
|
||||
}
|
||||
if (parsed.schemaVersion !== RECONCILIATION_SCHEMA_VERSION) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact schema version is invalid');
|
||||
}
|
||||
validateInputs(parsed.inputs as ArtifactInputDigest[]);
|
||||
validateCounts(parsed.counts as Record<string, number>);
|
||||
return parsed as unknown as ArtifactEnvelope;
|
||||
}
|
||||
|
||||
function validateRef(ref: ArtifactRef): void {
|
||||
if (!ARTIFACT_KINDS.includes(ref.artifactKind)) {
|
||||
throw new ArtifactIntegrityError('Artifact reference kind is invalid');
|
||||
}
|
||||
validateClass(ref.vulnerabilityClass);
|
||||
if (ref.schemaVersion !== RECONCILIATION_SCHEMA_VERSION) {
|
||||
throw new ArtifactIntegrityError('Artifact reference schema version is invalid');
|
||||
}
|
||||
validateDigest(ref.sha256, 'Artifact reference digest');
|
||||
validateInputs(ref.inputs);
|
||||
validateCounts(ref.counts);
|
||||
}
|
||||
|
||||
/** Read and verify one artifact's path, bytes, envelope metadata, and ordered lineage. */
|
||||
export async function readArtifact<TKind extends ArtifactKind>(
|
||||
ref: ArtifactRef<TKind>,
|
||||
sessionId: string,
|
||||
workspacesDir: string = WORKSPACES_DIR,
|
||||
): Promise<ArtifactBodyMap[TKind]> {
|
||||
validateRef(ref);
|
||||
const root = await resolveArtifactRoot(sessionId, ref.vulnerabilityClass, workspacesDir);
|
||||
// A reference carries its own path through Temporal history. Recompute the only path this
|
||||
// kind and digest may occupy and demand an exact match, so a tampered or stale ref cannot
|
||||
// point a read at an arbitrary file outside the class artifact root.
|
||||
const expectedPath = path.join(root, artifactFilename(ref.artifactKind, ref.sha256));
|
||||
if (!path.isAbsolute(ref.path) || path.normalize(ref.path) !== ref.path || ref.path !== expectedPath) {
|
||||
throw new ArtifactIntegrityError('Artifact reference path is not the expected contained path');
|
||||
}
|
||||
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await lstat(ref.path);
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) {
|
||||
throw new ReconciliationArtifactNotFoundError('Referenced reconciliation artifact is not visible');
|
||||
}
|
||||
throw new ReconciliationIoError('Unable to inspect referenced reconciliation artifact');
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
throw new ArtifactIntegrityError('Referenced reconciliation artifact is not a regular file');
|
||||
}
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(ref.path);
|
||||
} catch {
|
||||
throw new ReconciliationArtifactNotFoundError('Referenced reconciliation artifact is not visible');
|
||||
}
|
||||
if (resolvedPath !== expectedPath) {
|
||||
throw new ArtifactIntegrityError('Referenced reconciliation artifact resolves outside its expected path');
|
||||
}
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readFile(resolvedPath);
|
||||
} catch {
|
||||
throw new ReconciliationIoError('Unable to read referenced reconciliation artifact');
|
||||
}
|
||||
if (sha256Hex(bytes) !== ref.sha256) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact digest does not match its reference');
|
||||
}
|
||||
|
||||
const envelope = parseEnvelope(bytes);
|
||||
if (
|
||||
envelope.artifactKind !== ref.artifactKind ||
|
||||
envelope.vulnerabilityClass !== ref.vulnerabilityClass ||
|
||||
envelope.schemaVersion !== ref.schemaVersion ||
|
||||
!artifactInputsMatch(envelope.inputs, ref.inputs) ||
|
||||
!countsMatch(envelope.counts, ref.counts)
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Reconciliation artifact metadata or lineage does not match its reference');
|
||||
}
|
||||
return envelope.body as ArtifactBodyMap[TKind];
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/** Shared contracts for the single-scan reconciliation pipeline. */
|
||||
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import type {
|
||||
AuthFinding,
|
||||
AuthzFinding,
|
||||
InjectionFinding,
|
||||
MiscellaneousFinding,
|
||||
SsrfFinding,
|
||||
XssFinding,
|
||||
} from '../queue-schemas.js';
|
||||
|
||||
/** Which producer emitted an observation. */
|
||||
export type ScanSource = 'vulnerability_analysis' | 'sast';
|
||||
|
||||
/** Internal primary-selection preference. Never exposed to a model or consumer queue. */
|
||||
export type PrimaryPreference = 'default' | 'preferred';
|
||||
|
||||
/** Priority supplied by the SAST bridge. */
|
||||
export type Priority = 'P1' | 'P2' | 'P3';
|
||||
|
||||
/**
|
||||
* Authoritative SAST source location copied from validated SARIF.
|
||||
*
|
||||
* This is the exact file/line/column the static analysis engine pinned its finding to, carried
|
||||
* through reconciliation unchanged so a task's reported location always traces back to real
|
||||
* evidence rather than something reconstructed or guessed downstream.
|
||||
*/
|
||||
export interface SastSourceLocation {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
rule_id: string;
|
||||
}
|
||||
|
||||
// Widen each member of a union so the keys unique to its siblings are typed `never`. This lets one
|
||||
// evidence value be discriminated by which class's fields it carries, and makes assigning a foreign
|
||||
// class's field a compile error rather than a silently accepted extra property.
|
||||
type ExclusiveUnion<T, TAll = T> = T extends unknown
|
||||
? T & Partial<Record<Exclude<TAll extends unknown ? keyof TAll : never, keyof T>, never>>
|
||||
: never;
|
||||
|
||||
/** Class-specific evidence with the producer-owned `ID` removed. */
|
||||
export type ClassEvidence = ExclusiveUnion<
|
||||
| Omit<InjectionFinding, 'ID'>
|
||||
| Omit<XssFinding, 'ID'>
|
||||
| Omit<AuthFinding, 'ID'>
|
||||
| Omit<AuthzFinding, 'ID'>
|
||||
| Omit<SsrfFinding, 'ID'>
|
||||
| Omit<MiscellaneousFinding, 'ID'>
|
||||
>;
|
||||
|
||||
// A SAST-origin observation always declares `preferred`. This is the dedupe contract: when
|
||||
// reconciliation merges a SAST finding with a pentest finding for the same underlying bug, the
|
||||
// SAST evidence becomes the task's primary record (it carries an exact file/line/rule, while a
|
||||
// pentest finding does not), and the pentest observation survives only as a merged member.
|
||||
export interface SastProducerFields {
|
||||
producer_id: string;
|
||||
scan_source: 'sast';
|
||||
primary_preference: 'preferred';
|
||||
priority: Priority;
|
||||
sast_source_location: SastSourceLocation;
|
||||
}
|
||||
|
||||
// Pentest-origin observations always declare `default`, the losing side of the preference above.
|
||||
export interface VulnAnalysisProducerFields {
|
||||
producer_id: string;
|
||||
scan_source: 'vulnerability_analysis';
|
||||
primary_preference: 'default';
|
||||
}
|
||||
|
||||
export type ProducerFields = SastProducerFields | VulnAnalysisProducerFields;
|
||||
|
||||
// Once a group is collapsed into a task the primary is already chosen, so `primary_preference`
|
||||
// has done its job and is dropped. It must not survive into materialized tasks or published output.
|
||||
export type MaterializedProducerFields =
|
||||
| Omit<SastProducerFields, 'primary_preference'>
|
||||
| Omit<VulnAnalysisProducerFields, 'primary_preference'>;
|
||||
|
||||
/** One current observation before task formation. */
|
||||
export type ReconciliationObservation<E extends ClassEvidence = ClassEvidence> = E & ProducerFields;
|
||||
|
||||
/** One non-primary observation retained under a materialized task. */
|
||||
export type MergedObservation<E extends ClassEvidence = ClassEvidence> = E & MaterializedProducerFields;
|
||||
|
||||
/** One stable exploitation task before publication removes internal producer fields. */
|
||||
export type ReconciliationTask<E extends ClassEvidence = ClassEvidence> = E &
|
||||
MaterializedProducerFields & {
|
||||
ID: string;
|
||||
merged_from?: MergedObservation<E>[];
|
||||
};
|
||||
|
||||
/** The exact OSS intermediate artifact vocabulary, in stage order. */
|
||||
export type ArtifactKind = 'producer-observations' | 'supplemental-observations' | 'task-formation' | 'fixed-tasks';
|
||||
|
||||
// One entry in an artifact's lineage: which prior-stage artifact (by kind and exact content
|
||||
// digest) it was built from. A later stage checks these digests against the refs it was actually
|
||||
// handed, so it can refuse to proceed if its inputs were regenerated or swapped out from under it.
|
||||
export interface ArtifactInputDigest {
|
||||
artifactKind: ArtifactKind;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
/** Safe content-addressed metadata carried through Temporal history. */
|
||||
export interface ArtifactRef<TKind extends ArtifactKind = ArtifactKind> {
|
||||
path: string;
|
||||
artifactKind: TKind;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
schemaVersion: 1;
|
||||
sha256: string;
|
||||
inputs: ArtifactInputDigest[];
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Exact durable output set for one class publication. */
|
||||
export interface PublicationContract {
|
||||
publicationKind: 'class-reconciliation';
|
||||
schemaVersion: 1;
|
||||
manifestPath: string;
|
||||
requiredOutputPaths: readonly string[];
|
||||
}
|
||||
|
||||
/** History-safe aggregate metrics for one reconciled class. */
|
||||
export interface ReconciliationMetrics {
|
||||
alreadyPublished: boolean;
|
||||
durationMs: number;
|
||||
costUsd: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
modelCalls: number;
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/** Strict per-class SAST enrichment through the shared one-shot generation port. */
|
||||
|
||||
import { WORKSPACES_DIR } from '../../paths.js';
|
||||
import { loadPrompt } from '../../services/prompt-manager.js';
|
||||
import type { ActivityLogger } from '../../types/activity-logger.js';
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import type { SarifRef } from '../sast/types.js';
|
||||
import type { StructuredGenerationPort } from '../structured-generation.js';
|
||||
import { ArtifactIntegrityError, ReconciliationError, ReconciliationIoError, writeArtifact } from './artifact-store.js';
|
||||
import type { ReconciliationObservation } from './contracts.js';
|
||||
import { extractContext } from './sast/context-extractor.js';
|
||||
import { CWE_TO_CATEGORY, unmappedMapping, vulnerabilityClassToCategory } from './sast/cwe-mapper.js';
|
||||
import { runSastEnrichmentBatch, type SastEnrichmentBatchOutcome } from './sast/enrichment/batch.js';
|
||||
import { buildSastObservation, mintSastProducerId, sourceLocationFromContext } from './sast/enrichment/policy.js';
|
||||
import { enrichmentPromptName } from './sast/enrichment/schema.js';
|
||||
import {
|
||||
EnrichmentAttemptError,
|
||||
pairEnrichedVulnerabilities,
|
||||
type ValidationResult,
|
||||
} from './sast/enrichment/validate.js';
|
||||
import { readPinnedSarif, SarifIntakeError } from './sast/intake.js';
|
||||
import { parseSarifContent, SarifDocumentError } from './sast/sarif-parser.js';
|
||||
import type { ClassifiedFinding, DroppedSarifFinding, ParsedSarif, ParsedSarifFinding } from './sast/types.js';
|
||||
import type {
|
||||
EnrichSuccess,
|
||||
StageMetrics,
|
||||
SupplementalDropCounts,
|
||||
SupplementalDroppedFinding,
|
||||
SupplementalObservationsBody,
|
||||
} from './stage-contracts.js';
|
||||
|
||||
const ENRICHMENT_MAX_TOKENS = 32768;
|
||||
|
||||
export interface EnrichClassSastObservationsInput {
|
||||
sessionId: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
sarif?: SarifRef;
|
||||
}
|
||||
|
||||
export interface EnrichClassSastObservationsDeps<TModelContext> {
|
||||
generation: StructuredGenerationPort<TModelContext>;
|
||||
modelContextFor: (input: EnrichClassSastObservationsInput) => TModelContext;
|
||||
workspacesDir?: string;
|
||||
signalFor?: () => AbortSignal | undefined;
|
||||
promptLoader?: (vulnerabilityClass: ReconciliationClass) => Promise<string>;
|
||||
onMetrics?: (metrics: StageMetrics) => void;
|
||||
logger?: ActivityLogger;
|
||||
}
|
||||
|
||||
export class SastEnrichmentInputError extends ReconciliationError {
|
||||
constructor(message: string) {
|
||||
super(message, false, 'SastEnrichmentInputError');
|
||||
}
|
||||
}
|
||||
|
||||
export class SastEnrichmentModelError extends ReconciliationError {
|
||||
readonly metrics: StageMetrics;
|
||||
|
||||
constructor(message: string, metrics: StageMetrics, retryable = true) {
|
||||
super(message, retryable, 'SastEnrichmentModelError');
|
||||
this.metrics = metrics;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancellation marker translated into Temporal cancellation by the activity wrapper. */
|
||||
export class SastEnrichmentCancelledError extends Error {
|
||||
constructor() {
|
||||
super('SAST enrichment was cancelled');
|
||||
this.name = 'AbortError';
|
||||
}
|
||||
}
|
||||
|
||||
const NOOP_LOGGER: ActivityLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
function zeroDrops(): SupplementalDropCounts {
|
||||
return {
|
||||
unknown_cwe: 0,
|
||||
other_category: 0,
|
||||
malformed: 0,
|
||||
orphaned: 0,
|
||||
duplicate_sast_id: 0,
|
||||
enrichment_dropped: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function zeroMetrics(): StageMetrics {
|
||||
return { costUsd: 0, modelCalls: 0, inputTokens: 0, outputTokens: 0 };
|
||||
}
|
||||
|
||||
interface SupplementalCounts {
|
||||
sarif_findings: number;
|
||||
sarif_dropped: number;
|
||||
sent: number;
|
||||
returned: number;
|
||||
accepted: number;
|
||||
}
|
||||
|
||||
function emptySupplementalCounts(): SupplementalCounts {
|
||||
return { sarif_findings: 0, sarif_dropped: 0, sent: 0, returned: 0, accepted: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Names every finding that was sent for enrichment and did not come back paired.
|
||||
*
|
||||
* A count alone cannot say what was lost. Identity is recovered from the sent side by set
|
||||
* difference rather than from the response, because a malformed response may carry no usable
|
||||
* id at all — which is exactly what made it malformed.
|
||||
*/
|
||||
export function computeDroppedFindings(
|
||||
findingsById: ReadonlyMap<number, ClassifiedFinding>,
|
||||
paired: readonly { sastId: number }[],
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): SupplementalDroppedFinding[] {
|
||||
const pairedSastIds = new Set(paired.map(({ sastId }) => sastId));
|
||||
const dropped = [...findingsById.entries()]
|
||||
.filter(([sastId]) => !pairedSastIds.has(sastId))
|
||||
.sort(([first], [second]) => first - second)
|
||||
.map(([sastId, finding]) => ({
|
||||
producer_id: mintSastProducerId(vulnerabilityClass, sastId),
|
||||
sast_id: sastId,
|
||||
sast_source_location: sourceLocationFromContext(finding.context),
|
||||
}));
|
||||
// Every sent finding is either accepted or named as dropped. A shortfall means the validator
|
||||
// paired an id that was never sent, or paired one twice, which is an integrity fault rather
|
||||
// than model output to accept.
|
||||
if (dropped.length + paired.length !== findingsById.size) {
|
||||
throw new ArtifactIntegrityError('SAST enrichment dropped-identity accounting failed');
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
async function writeSupplemental(
|
||||
input: EnrichClassSastObservationsInput,
|
||||
workspacesDir: string,
|
||||
observations: ReconciliationObservation[],
|
||||
drops: SupplementalDropCounts,
|
||||
droppedFindings: SupplementalDroppedFinding[],
|
||||
counts: SupplementalCounts,
|
||||
metrics: StageMetrics,
|
||||
): Promise<EnrichSuccess> {
|
||||
const body: SupplementalObservationsBody = {
|
||||
observations,
|
||||
provenance: [],
|
||||
...(input.sarif !== undefined && { sarif: input.sarif }),
|
||||
drops,
|
||||
dropped_findings: droppedFindings,
|
||||
};
|
||||
const ref = await writeArtifact({
|
||||
sessionId: input.sessionId,
|
||||
workspacesDir,
|
||||
artifactKind: 'supplemental-observations',
|
||||
vulnerabilityClass: input.vulnerabilityClass,
|
||||
body,
|
||||
inputs: [],
|
||||
counts: { observations: observations.length, ...counts, ...drops },
|
||||
});
|
||||
return { ref, metrics };
|
||||
}
|
||||
|
||||
function droppedFindingBelongsToClass(finding: DroppedSarifFinding, vulnerabilityClass: ReconciliationClass): boolean {
|
||||
const mapping =
|
||||
finding.ruleId === undefined
|
||||
? unmappedMapping('malformed')
|
||||
: (CWE_TO_CATEGORY[finding.ruleId] ?? unmappedMapping(finding.ruleId));
|
||||
return mapping.category === vulnerabilityClassToCategory(vulnerabilityClass);
|
||||
}
|
||||
|
||||
function classifyFindings(
|
||||
findings: readonly ParsedSarifFinding[],
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
drops: SupplementalDropCounts,
|
||||
): ClassifiedFinding[] {
|
||||
const target = vulnerabilityClassToCategory(vulnerabilityClass);
|
||||
const classified: ClassifiedFinding[] = [];
|
||||
for (const finding of findings) {
|
||||
const known = CWE_TO_CATEGORY[finding.result.ruleId];
|
||||
const mapping = known ?? unmappedMapping(finding.result.ruleId);
|
||||
if (mapping.category !== target) {
|
||||
drops.other_category++;
|
||||
continue;
|
||||
}
|
||||
if (known === undefined) drops.unknown_cwe++;
|
||||
classified.push({ context: extractContext(finding.result), mapping, phase: finding.phase });
|
||||
}
|
||||
return classified;
|
||||
}
|
||||
|
||||
async function defaultPromptLoader(vulnerabilityClass: ReconciliationClass, logger: ActivityLogger): Promise<string> {
|
||||
return loadPrompt(
|
||||
enrichmentPromptName(vulnerabilityClass),
|
||||
{
|
||||
webUrl: 'https://not-applicable.invalid',
|
||||
repoPath: '/repo',
|
||||
AUTH_STATE_FILE: '/tmp/auth-state.json',
|
||||
},
|
||||
null,
|
||||
false,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
function isCancellation(error: unknown, signal: AbortSignal | undefined): boolean {
|
||||
if (signal?.aborted !== true) return false;
|
||||
|
||||
let current: unknown = error;
|
||||
const seen = new Set<unknown>();
|
||||
for (let depth = 0; depth < 8 && current !== undefined && current !== null && !seen.has(current); depth++) {
|
||||
if (current === signal.reason) return true;
|
||||
seen.add(current);
|
||||
const errorName = current instanceof Error ? current.name : undefined;
|
||||
if (errorName === 'AbortError' || errorName === 'CancelledFailure') return true;
|
||||
current = current instanceof Error ? current.cause : undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isRetryableFileSystemError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (
|
||||
code !== undefined &&
|
||||
['EACCES', 'EAGAIN', 'EBUSY', 'EIO', 'EMFILE', 'ENFILE', 'ENOMEM', 'ENOSPC', 'EPERM', 'EROFS', 'ESTALE'].includes(
|
||||
code,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return /\b(?:EACCES|EAGAIN|EBUSY|EIO|EMFILE|ENFILE|ENOMEM|ENOSPC|EPERM|EROFS|ESTALE)\b/u.test(error.message);
|
||||
}
|
||||
|
||||
function metricsFailure(
|
||||
onMetrics: ((metrics: StageMetrics) => void) | undefined,
|
||||
metrics: StageMetrics,
|
||||
): ReconciliationIoError | undefined {
|
||||
try {
|
||||
onMetrics?.({ ...metrics });
|
||||
return undefined;
|
||||
} catch {
|
||||
return new ReconciliationIoError('Unable to record SAST enrichment usage');
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the pure stage with explicit model, cancellation, metrics, and logging bindings. */
|
||||
export function createEnrichClassSastObservations<TModelContext>(
|
||||
deps: EnrichClassSastObservationsDeps<TModelContext>,
|
||||
): (input: EnrichClassSastObservationsInput) => Promise<EnrichSuccess> {
|
||||
return async function enrichClassSastObservations(input: EnrichClassSastObservationsInput): Promise<EnrichSuccess> {
|
||||
const logger = deps.logger ?? NOOP_LOGGER;
|
||||
const workspacesDir = deps.workspacesDir ?? WORKSPACES_DIR;
|
||||
const drops = zeroDrops();
|
||||
|
||||
// 1. Absence is a successful, exact empty artifact and never resolves a model.
|
||||
if (input.sarif === undefined) {
|
||||
return writeSupplemental(input, workspacesDir, [], drops, [], emptySupplementalCounts(), zeroMetrics());
|
||||
}
|
||||
|
||||
// 2. The reference is contained and rehashed before any JSON parsing.
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readPinnedSarif(input.sessionId, input.sarif, workspacesDir);
|
||||
} catch (error) {
|
||||
if (error instanceof SarifIntakeError) {
|
||||
if (error.kind === 'io') {
|
||||
throw new ReconciliationIoError('Unable to read the pinned SARIF input');
|
||||
}
|
||||
throw new SastEnrichmentInputError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 3. Run/document violations fail the class. Per-finding violations drop only that finding.
|
||||
let parsed: ParsedSarif;
|
||||
try {
|
||||
parsed = parseSarifContent(bytes.toString('utf8'));
|
||||
} catch (error) {
|
||||
if (error instanceof SarifDocumentError) throw new SastEnrichmentInputError(error.message);
|
||||
throw error;
|
||||
}
|
||||
const classDropped = parsed.droppedFindings.filter((finding) =>
|
||||
droppedFindingBelongsToClass(finding, input.vulnerabilityClass),
|
||||
).length;
|
||||
drops.malformed = classDropped;
|
||||
const classified = classifyFindings(parsed.findings, input.vulnerabilityClass, drops);
|
||||
const counts: SupplementalCounts = {
|
||||
sarif_findings: classified.length,
|
||||
sarif_dropped: classDropped,
|
||||
sent: 0,
|
||||
returned: 0,
|
||||
accepted: 0,
|
||||
};
|
||||
|
||||
// 4. A schema-valid empty current class batch is another zero-request path.
|
||||
if (classified.length === 0) {
|
||||
return writeSupplemental(input, workspacesDir, [], drops, [], counts, zeroMetrics());
|
||||
}
|
||||
|
||||
const signal = deps.signalFor?.();
|
||||
if (signal?.aborted === true) throw new SastEnrichmentCancelledError();
|
||||
|
||||
let prompt: string;
|
||||
try {
|
||||
prompt = deps.promptLoader
|
||||
? await deps.promptLoader(input.vulnerabilityClass)
|
||||
: await defaultPromptLoader(input.vulnerabilityClass, logger);
|
||||
} catch (error) {
|
||||
if (isRetryableFileSystemError(error)) {
|
||||
throw new ReconciliationIoError('Unable to read the SAST enrichment prompt');
|
||||
}
|
||||
throw new SastEnrichmentInputError('SAST enrichment prompt is unavailable');
|
||||
}
|
||||
|
||||
// `sastId` is a plain 0-based index into this batch, not a producer ID: the model only ever sees
|
||||
// this small integer (as `_sastId` below), never the eventual SAST-namespaced producer ID that
|
||||
// `mintSastProducerId` derives from it after a response comes back paired.
|
||||
const idAssigned = classified.map((finding, index) => ({ sastId: index, finding }));
|
||||
const findingsJson = JSON.stringify(
|
||||
idAssigned.map(({ sastId, finding }) => ({ _sastId: sastId, ...finding.context })),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const findingsById = new Map(idAssigned.map(({ sastId, finding }) => [sastId, finding]));
|
||||
const metrics = zeroMetrics();
|
||||
counts.sent = classified.length;
|
||||
|
||||
// 5. One nonempty class batch makes exactly one billable request.
|
||||
let outcome: SastEnrichmentBatchOutcome;
|
||||
metrics.modelCalls = 1;
|
||||
try {
|
||||
outcome = await runSastEnrichmentBatch(deps.generation, deps.modelContextFor(input), {
|
||||
vulnerabilityClass: input.vulnerabilityClass,
|
||||
prompt,
|
||||
findingsJson,
|
||||
maxTokens: ENRICHMENT_MAX_TOKENS,
|
||||
...(signal !== undefined && { signal }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCancellation(error, signal)) throw new SastEnrichmentCancelledError();
|
||||
metricsFailure(deps.onMetrics, metrics);
|
||||
throw new SastEnrichmentModelError('SAST enrichment request failed', { ...metrics });
|
||||
}
|
||||
metrics.costUsd = outcome.usage.costUsd;
|
||||
metrics.inputTokens = outcome.usage.inputTokens;
|
||||
metrics.outputTokens = outcome.usage.outputTokens;
|
||||
// Record usage now so spend is captured even on a later abort or integrity failure, but hold any
|
||||
// ledger error and rethrow it only after the model-outcome and accounting checks below, so a
|
||||
// metrics-write fault never masks a real enrichment failure.
|
||||
const usageLedgerFailure = metricsFailure(deps.onMetrics, metrics);
|
||||
|
||||
if (outcome.status === 'aborted') throw new SastEnrichmentCancelledError();
|
||||
if (outcome.status === 'failed') {
|
||||
throw new SastEnrichmentModelError(outcome.message, { ...metrics }, !outcome.terminal);
|
||||
}
|
||||
|
||||
// 6. Pair by the code-owned id and account for every returned and sent element.
|
||||
let validated: ValidationResult;
|
||||
try {
|
||||
validated = pairEnrichedVulnerabilities(outcome.vulnerabilities, findingsById, input.vulnerabilityClass);
|
||||
} catch (error) {
|
||||
if (error instanceof EnrichmentAttemptError) {
|
||||
throw new SastEnrichmentModelError(error.message, { ...metrics });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Every returned element must land in exactly one bucket: paired, malformed, orphaned, or
|
||||
// duplicate. If the buckets do not sum to the returned count, the validator dropped or
|
||||
// double-counted something, which is an integrity fault rather than model output to accept.
|
||||
const { paired, counts: validationCounts } = validated;
|
||||
const accountedReturned =
|
||||
paired.length + validationCounts.malformed + validationCounts.orphaned + validationCounts.duplicate_sast_id;
|
||||
if (accountedReturned !== validationCounts.returned) {
|
||||
throw new ArtifactIntegrityError('SAST enrichment returned-output accounting failed');
|
||||
}
|
||||
if (paired.length > classified.length) {
|
||||
throw new ArtifactIntegrityError('SAST enrichment accepted more findings than were sent');
|
||||
}
|
||||
if (usageLedgerFailure !== undefined) throw usageLedgerFailure;
|
||||
|
||||
drops.malformed += validationCounts.malformed;
|
||||
drops.orphaned = validationCounts.orphaned;
|
||||
drops.duplicate_sast_id = validationCounts.duplicate_sast_id;
|
||||
drops.enrichment_dropped = classified.length - paired.length;
|
||||
counts.returned = validationCounts.returned;
|
||||
counts.accepted = paired.length;
|
||||
|
||||
const droppedFindings = computeDroppedFindings(findingsById, paired, input.vulnerabilityClass);
|
||||
|
||||
const pairedInSarifOrder = [...paired].sort((first, second) => first.sastId - second.sastId);
|
||||
const observations = pairedInSarifOrder.map(({ finding, sastId, evidence }) =>
|
||||
buildSastObservation(mintSastProducerId(input.vulnerabilityClass, sastId), evidence, finding),
|
||||
);
|
||||
|
||||
if (drops.enrichment_dropped > 0) {
|
||||
const droppedSummary = droppedFindings
|
||||
.map(
|
||||
({ producer_id, sast_source_location }) =>
|
||||
`${producer_id} (${sast_source_location.rule_id} at ${sast_source_location.file}:${sast_source_location.line})`,
|
||||
)
|
||||
.join(', ');
|
||||
logger.warn(
|
||||
`Static-analysis enrichment: ${drops.enrichment_dropped} of ${counts.sent} findings could not be enriched and were used as-is: ${droppedSummary}.`,
|
||||
);
|
||||
}
|
||||
if (drops.unknown_cwe > 0) {
|
||||
logger.info(
|
||||
`Static-analysis enrichment: ${drops.unknown_cwe} findings had an unrecognised CWE and were grouped under "miscellaneous".`,
|
||||
);
|
||||
}
|
||||
return writeSupplemental(input, workspacesDir, observations, drops, droppedFindings, counts, metrics);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// 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.
|
||||
|
||||
/** Pass 1 task formation over current observations only. */
|
||||
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_DELIVERABLES_SUBDIR, WORKSPACES_DIR } from '../../paths.js';
|
||||
import { loadPrompt } from '../../services/prompt-manager.js';
|
||||
import type { ActivityLogger } from '../../types/activity-logger.js';
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import { materializeSourceJail } from '../pi/source-jail.js';
|
||||
import {
|
||||
isTaskFormationFallbackReason,
|
||||
type TaskFormationExecutionContext,
|
||||
type TaskFormationExecutor,
|
||||
TaskFormationExecutorError,
|
||||
type TaskFormationExecutorResult,
|
||||
type TaskFormationFallbackReason,
|
||||
type TaskFormationUsage,
|
||||
taskFormationExecutor,
|
||||
} from '../pi/task-formation-executor.js';
|
||||
import { ArtifactIntegrityError, ReconciliationIoError, readArtifact, writeArtifact } from './artifact-store.js';
|
||||
import type { ArtifactRef, ReconciliationObservation } from './contracts.js';
|
||||
import { mintLabels } from './labels.js';
|
||||
import { findLeakedProducerIds, toObservationView } from './observation-view.js';
|
||||
import { combineObservations } from './observations.js';
|
||||
import type { FormSuccess, StageMetrics, TaskFormationBody, TaskFormationInput } from './stage-contracts.js';
|
||||
import { SINGLETON_FALLBACK } from './stage-contracts.js';
|
||||
import { createValidatingSubmitTool } from './submit-validation.js';
|
||||
import { acceptTaskGroups, buildTaskFormationSchema, findTaskFormationProblems } from './task-formation-schema.js';
|
||||
|
||||
// Two copies of the same pattern, not one shared regex: the global-flagged one is used with
|
||||
// .replace() below, while the non-global one is used with .test(). A global regex carries a
|
||||
// stateful lastIndex across calls to .test(), so reusing one instance for both would make a later
|
||||
// leak check silently start scanning mid-string instead of from the beginning.
|
||||
const INTERNAL_REFERENCE_PATTERN = /\b(?:AUTHZ|MISC|AUTH|INJ|XSS|SSRF)(?:-(?:VULN|SAST))?-[0-9]+\b/gu;
|
||||
const INTERNAL_REFERENCE_LEAK_PATTERN = /\b(?:AUTHZ|MISC|AUTH|INJ|XSS|SSRF)(?:-(?:VULN|SAST))?-[0-9]+\b/u;
|
||||
const TRANSIENT_IO_CODE_PATTERN = /\b(?:EAGAIN|EBUSY|EIO|EMFILE|ENFILE|ENOMEM|ENOSPC|EROFS|ETIMEDOUT)\b/u;
|
||||
// The model-facing forbidden-key set for the task-formation boundary. It is a sibling of, but not
|
||||
// identical to, `FORBIDDEN_PUBLISHED_KEYS` in publish.ts and prepare.ts: this one guards the
|
||||
// pre-grouping observation view (which can still carry an `ID` or `merged_from` from an earlier
|
||||
// stage's shape), while the published-queue set guards the post-materialization task shape. Each
|
||||
// must independently list every internal-only key for its own boundary; neither can be derived from
|
||||
// the other.
|
||||
const FORBIDDEN_MODEL_KEYS = new Set([
|
||||
'ID',
|
||||
'_sastId',
|
||||
'merged_from',
|
||||
'novelty',
|
||||
'observation_key',
|
||||
'primary_preference',
|
||||
'producer_id',
|
||||
]);
|
||||
|
||||
export interface FormClassExploitTasksInput {
|
||||
readonly sessionId: string;
|
||||
readonly vulnerabilityClass: ReconciliationClass;
|
||||
readonly repositoryPath: string;
|
||||
readonly producerRef: ArtifactRef<'producer-observations'>;
|
||||
readonly supplementalRef: ArtifactRef<'supplemental-observations'>;
|
||||
readonly deliverablesSubdir?: string;
|
||||
readonly webUrl?: string;
|
||||
}
|
||||
|
||||
export interface FormClassExploitTasksResult extends FormSuccess {
|
||||
readonly model?: string;
|
||||
}
|
||||
|
||||
export interface FormClassExploitTasksDeps {
|
||||
readonly executor?: TaskFormationExecutor;
|
||||
readonly workspacesDir?: string;
|
||||
readonly signalFor?: () => AbortSignal | undefined;
|
||||
readonly executionContextFor?: () => TaskFormationExecutionContext | undefined;
|
||||
readonly executorTimeoutMsFor?: () => number | undefined;
|
||||
readonly onMetrics?: (metrics: StageMetrics) => void;
|
||||
readonly logger?: ActivityLogger;
|
||||
}
|
||||
|
||||
/** Failure that Temporal may retry and, only after exhaustion, classify for singleton fallback. */
|
||||
export class TaskFormationModelError extends Error {
|
||||
override readonly name = 'TaskFormationModelError';
|
||||
readonly failureType = 'TaskFormationModelError' as const;
|
||||
readonly retryable: boolean;
|
||||
readonly fallbackReason: TaskFormationFallbackReason | undefined;
|
||||
readonly metrics: StageMetrics;
|
||||
|
||||
constructor(options: {
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
fallbackReason?: TaskFormationFallbackReason;
|
||||
metrics: StageMetrics;
|
||||
}) {
|
||||
super(options.message);
|
||||
this.retryable = options.retryable;
|
||||
this.fallbackReason = options.fallbackReason;
|
||||
this.metrics = options.metrics;
|
||||
}
|
||||
}
|
||||
|
||||
const NOOP_LOGGER: ActivityLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
function zeroMetrics(): StageMetrics {
|
||||
return { costUsd: 0, modelCalls: 0, inputTokens: 0, outputTokens: 0 };
|
||||
}
|
||||
|
||||
function metricsFromUsage(usage: TaskFormationUsage, modelCalls: number): StageMetrics {
|
||||
return {
|
||||
costUsd: usage.costUsd,
|
||||
modelCalls,
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
function cancellationError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason;
|
||||
return new DOMException('Task formation was cancelled.', 'AbortError');
|
||||
}
|
||||
|
||||
function checkCancellation(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted === true) throw cancellationError(signal);
|
||||
}
|
||||
|
||||
// The depth cap bounds how far this walks an error's `cause`/`context`/`originalError` chain
|
||||
// looking for a transient I/O code. It exists only to stop a pathological or circular chain from
|
||||
// recursing forever; ordinary wrapped errors are a handful of layers deep at most.
|
||||
function isTransientPromptIoFailure(error: unknown, depth = 0): boolean {
|
||||
if (depth > 4) return false;
|
||||
if (typeof error === 'string') return TRANSIENT_IO_CODE_PATTERN.test(error);
|
||||
if (typeof error !== 'object' || error === null) return false;
|
||||
|
||||
if ('code' in error && typeof error.code === 'string' && TRANSIENT_IO_CODE_PATTERN.test(error.code)) return true;
|
||||
if ('cause' in error && isTransientPromptIoFailure(error.cause, depth + 1)) return true;
|
||||
if ('context' in error && isTransientPromptIoFailure(error.context, depth + 1)) return true;
|
||||
if ('originalError' in error && isTransientPromptIoFailure(error.originalError, depth + 1)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// This is a distinct pass from the producer-ID redaction in observation-view.ts: that one redacts
|
||||
// producer IDs it already knows about (because they were passed in), while this one redacts any
|
||||
// string that merely looks like an internal class/reference token (e.g. an INJ-VULN-03-shaped
|
||||
// substring), including one that might appear inside free-text evidence rather than as an ID field.
|
||||
function scrubInternalReferences(value: unknown): unknown {
|
||||
if (typeof value === 'string') return value.replace(INTERNAL_REFERENCE_PATTERN, '[redacted]');
|
||||
if (Array.isArray(value)) return value.map(scrubInternalReferences);
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) output[key] = scrubInternalReferences(item);
|
||||
return output;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function findForbiddenKeys(value: unknown, found: Set<string> = new Set()): Set<string> {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) findForbiddenKeys(item, found);
|
||||
return found;
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (FORBIDDEN_MODEL_KEYS.has(key)) found.add(key);
|
||||
findForbiddenKeys(item, found);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function taskFormationPromptName(vulnerabilityClass: ReconciliationClass): string {
|
||||
return `task-formation-${vulnerabilityClass}`;
|
||||
}
|
||||
|
||||
// A filesystem fault reading the prompt is retryable (the prompt file is expected to exist and a
|
||||
// transient read failure should not fail the class outright), while any other failure means the
|
||||
// prompt itself is missing or unreadable content, which Temporal should not spend retries on.
|
||||
async function loadClassPolicy(
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
jailPath: string,
|
||||
webUrl: string,
|
||||
logger: ActivityLogger,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await loadPrompt(
|
||||
taskFormationPromptName(vulnerabilityClass),
|
||||
{ webUrl, repoPath: jailPath, AUTH_STATE_FILE: '' },
|
||||
null,
|
||||
false,
|
||||
logger,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isTransientPromptIoFailure(error)) {
|
||||
throw new ReconciliationIoError('The fixed task-formation class policy prompt could not be read');
|
||||
}
|
||||
throw new ArtifactIntegrityError('The fixed task-formation class policy prompt is unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exact prompt-facing input for task formation, so that the model that groups
|
||||
* observations into exploitation tasks never sees a producer ID or other internal identity.
|
||||
*
|
||||
* Each observation gets a short opaque label (minted disjoint from every producer ID in this
|
||||
* batch) that the model uses to refer to it instead of its real identity; the label-to-ID mapping
|
||||
* stays code-side. This is the point in the pipeline where the internal-identity boundary is
|
||||
* actually built, not merely checked: if this function stopped minting labels and passed producer
|
||||
* IDs through instead, a downstream exploit agent reading the eventual published queue would learn
|
||||
* exactly which scan producer (and by extension, which internal class/source combination) found
|
||||
* each vulnerability, which the reconciliation contract exists to prevent.
|
||||
*/
|
||||
function buildModelInput(
|
||||
observations: readonly ReconciliationObservation[],
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): {
|
||||
readonly input: TaskFormationInput;
|
||||
readonly labelToProducerId: ReadonlyMap<string, string>;
|
||||
readonly serialized: string;
|
||||
} {
|
||||
const producerIds = observations.map((observation) => observation.producer_id);
|
||||
const labels = mintLabels(observations.length, { taken: new Set(producerIds) });
|
||||
const labelToProducerId = new Map<string, string>();
|
||||
const queued_findings = observations.map((observation, index) => {
|
||||
const label = labels[index] as string;
|
||||
labelToProducerId.set(label, observation.producer_id);
|
||||
const projected = toObservationView(observation, vulnerabilityClass, producerIds);
|
||||
return { label, entry: scrubInternalReferences(projected) };
|
||||
}) as TaskFormationInput['queued_findings'];
|
||||
const input: TaskFormationInput = { queued_findings };
|
||||
|
||||
// First gate: a structural check that no forbidden key name made it into the projected shape at
|
||||
// all. The serialized-string check below is the second, independent gate against the same
|
||||
// failure mode, catching a producer ID or reference token that leaked as a value rather than a key.
|
||||
const forbiddenKeys = findForbiddenKeys(input);
|
||||
if (forbiddenKeys.size > 0) {
|
||||
throw new ArtifactIntegrityError('An internal key survived the task-formation positive projection');
|
||||
}
|
||||
|
||||
// Final gate before the prompt bytes are built: fail closed if any producer ID or internal
|
||||
// reference token survived projection and scrubbing, so the model never sees internal identity.
|
||||
const serialized = JSON.stringify(input, null, 2);
|
||||
if (findLeakedProducerIds(serialized, producerIds).length > 0 || INTERNAL_REFERENCE_LEAK_PATTERN.test(serialized)) {
|
||||
throw new ArtifactIntegrityError('An internal reference survived the task-formation positive projection');
|
||||
}
|
||||
return { input, labelToProducerId, serialized };
|
||||
}
|
||||
|
||||
function validateInputRefs(input: FormClassExploitTasksInput): void {
|
||||
if (
|
||||
input.producerRef.vulnerabilityClass !== input.vulnerabilityClass ||
|
||||
input.supplementalRef.vulnerabilityClass !== input.vulnerabilityClass
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Task formation received a cross-class artifact reference');
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFormationArtifact(
|
||||
input: FormClassExploitTasksInput,
|
||||
workspacesDir: string,
|
||||
body: TaskFormationBody,
|
||||
): Promise<ArtifactRef<'task-formation'>> {
|
||||
return writeArtifact({
|
||||
sessionId: input.sessionId,
|
||||
workspacesDir,
|
||||
artifactKind: 'task-formation',
|
||||
vulnerabilityClass: input.vulnerabilityClass,
|
||||
body,
|
||||
inputs: [
|
||||
{ artifactKind: 'producer-observations', sha256: input.producerRef.sha256 },
|
||||
{ artifactKind: 'supplemental-observations', sha256: input.supplementalRef.sha256 },
|
||||
],
|
||||
counts: {
|
||||
accepted_groups: body.groups.length,
|
||||
rejected_groups: body.rejected_group_count,
|
||||
dropped_unknown_labels: body.dropped_unknown_label_count,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether an exhausted error is one of the three locked semantic-fallback cases. */
|
||||
export function isSingletonFallbackEligible(error: unknown): error is TaskFormationModelError {
|
||||
return (
|
||||
error instanceof TaskFormationModelError && error.retryable && isTaskFormationFallbackReason(error.fallbackReason)
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the sentinel only for an exhausted eligible model-stage failure; otherwise rethrow. */
|
||||
export function singletonFallbackAfterExhaustion(error: unknown): typeof SINGLETON_FALLBACK {
|
||||
if (isSingletonFallbackEligible(error)) return SINGLETON_FALLBACK;
|
||||
throw error;
|
||||
}
|
||||
|
||||
/** Build the Pass 1 stage with explicit executor, workspace, cancellation, and metric bindings. */
|
||||
export function createFormClassExploitTasks(
|
||||
deps: FormClassExploitTasksDeps = {},
|
||||
): (input: FormClassExploitTasksInput) => Promise<FormClassExploitTasksResult> {
|
||||
const executor = deps.executor ?? taskFormationExecutor;
|
||||
const workspacesDir = deps.workspacesDir ?? WORKSPACES_DIR;
|
||||
const logger = deps.logger ?? NOOP_LOGGER;
|
||||
|
||||
return async function formClassExploitTasks(input: FormClassExploitTasksInput): Promise<FormClassExploitTasksResult> {
|
||||
validateInputRefs(input);
|
||||
const signal = deps.signalFor?.();
|
||||
checkCancellation(signal);
|
||||
|
||||
const producer = await readArtifact(input.producerRef, input.sessionId, workspacesDir);
|
||||
const supplemental = await readArtifact(input.supplementalRef, input.sessionId, workspacesDir);
|
||||
checkCancellation(signal);
|
||||
const observations = combineObservations(producer.observations, supplemental.observations);
|
||||
|
||||
// Fewer than two observations can form no group, so skip the model entirely and write an empty
|
||||
// formation with zero cost. This is one of the paths that leaves `model_ran` false.
|
||||
if (observations.length < 2) {
|
||||
const body: TaskFormationBody = {
|
||||
model_ran: false,
|
||||
groups: [],
|
||||
rejected_group_count: 0,
|
||||
dropped_unknown_label_count: 0,
|
||||
};
|
||||
const metrics = zeroMetrics();
|
||||
const ref = await writeFormationArtifact(input, workspacesDir, body);
|
||||
deps.onMetrics?.(metrics);
|
||||
return { ref, metrics };
|
||||
}
|
||||
|
||||
const modelInput = buildModelInput(observations, input.vulnerabilityClass);
|
||||
const labelSet = new Set(modelInput.input.queued_findings.map(({ label }) => label));
|
||||
const submitTool = createValidatingSubmitTool(buildTaskFormationSchema([...labelSet]), (parameters) =>
|
||||
findTaskFormationProblems(parameters, labelSet),
|
||||
);
|
||||
const deliverablesPath = path.resolve(
|
||||
input.repositoryPath,
|
||||
input.deliverablesSubdir ?? DEFAULT_DELIVERABLES_SUBDIR,
|
||||
);
|
||||
const reconciliationWorkspacePath = path.resolve(workspacesDir, input.sessionId, '.shannon', 'reconciliation');
|
||||
// Task formation runs against a disposable copy of the source tree rather than the live
|
||||
// repository or the deliverables directory, so the model's tool calls during this stage cannot
|
||||
// read or modify anything outside what it was actually given to reason about.
|
||||
const jail = await materializeSourceJail({
|
||||
sourceRoot: input.repositoryPath,
|
||||
deliverablesPath,
|
||||
reconciliationWorkspacePath,
|
||||
...(signal !== undefined && { signal }),
|
||||
});
|
||||
|
||||
let formation: FormClassExploitTasksResult;
|
||||
try {
|
||||
const classPolicy = await loadClassPolicy(
|
||||
input.vulnerabilityClass,
|
||||
jail.dir,
|
||||
input.webUrl ?? 'https://not-applicable.invalid',
|
||||
logger,
|
||||
);
|
||||
checkCancellation(signal);
|
||||
|
||||
let modelResult: TaskFormationExecutorResult;
|
||||
try {
|
||||
const executorTimeoutMs = deps.executorTimeoutMsFor?.();
|
||||
modelResult = await executor.run({
|
||||
cwd: jail.dir,
|
||||
systemPrompt: classPolicy,
|
||||
modelContext: modelInput.serialized,
|
||||
deniedPaths: jail.deniedPaths,
|
||||
submitTool,
|
||||
signal: signal ?? new AbortController().signal,
|
||||
...(executorTimeoutMs !== undefined && { timeoutMs: executorTimeoutMs }),
|
||||
correlation: {
|
||||
...deps.executionContextFor?.(),
|
||||
stage: 'task-formation',
|
||||
vulnerabilityClass: input.vulnerabilityClass,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof TaskFormationExecutorError)) throw error;
|
||||
if (error.failureKind === 'infrastructure') {
|
||||
throw new ReconciliationIoError(
|
||||
'Task-formation executor setup encountered a retryable infrastructure failure',
|
||||
);
|
||||
}
|
||||
if (error.failureKind !== 'model') throw error;
|
||||
const metrics = metricsFromUsage(error.usage, error.modelCalls);
|
||||
deps.onMetrics?.(metrics);
|
||||
throw new TaskFormationModelError({
|
||||
message: error.message,
|
||||
retryable: error.retryable,
|
||||
...(error.fallbackReason !== undefined && { fallbackReason: error.fallbackReason }),
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = metricsFromUsage(modelResult.usage, modelResult.modelCalls);
|
||||
deps.onMetrics?.(metrics);
|
||||
checkCancellation(signal);
|
||||
const accepted = acceptTaskGroups(modelResult.output, labelSet);
|
||||
const groups = accepted.groups.map((group) => ({
|
||||
producer_ids: group.queue_labels.map((label) => {
|
||||
const producerId = modelInput.labelToProducerId.get(label);
|
||||
if (producerId === undefined) {
|
||||
throw new ArtifactIntegrityError('An accepted task-formation label has no observation mapping');
|
||||
}
|
||||
return producerId;
|
||||
}),
|
||||
reasoning: group.reasoning,
|
||||
}));
|
||||
const body: TaskFormationBody = {
|
||||
model_ran: true,
|
||||
groups,
|
||||
rejected_group_count: accepted.rejectedGroupCount,
|
||||
dropped_unknown_label_count: accepted.droppedUnknownLabelCount,
|
||||
};
|
||||
const ref = await writeFormationArtifact(input, workspacesDir, body);
|
||||
formation = { ref, metrics, model: `${modelResult.providerId}:${modelResult.modelId}` };
|
||||
} catch (error) {
|
||||
// A primary error — including cancellation — already owns the outcome, so a cleanup failure
|
||||
// is logged and swallowed rather than replacing that error's type or cause chain.
|
||||
try {
|
||||
await jail.cleanup();
|
||||
} catch {
|
||||
logger.error(
|
||||
'A temporary copy of your source code could not be removed after analysis. It is inside the scan workspace and is safe to delete.',
|
||||
{
|
||||
stage: 'task-formation',
|
||||
vulnerabilityClass: input.vulnerabilityClass,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Nothing else is in flight after a successful formation, so an unremoved or unverifiable jail
|
||||
// is the stage's outcome: it leaves a full copy of the scanned tree on disk and fails here.
|
||||
await jail.cleanup();
|
||||
return formation;
|
||||
};
|
||||
}
|
||||
|
||||
export const formClassExploitTasks = createFormClassExploitTasks();
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Opaque, call-local labels used at reconciliation model boundaries.
|
||||
*
|
||||
* The model groups observations by these labels instead of by producer ID, so no internal producer
|
||||
* identity has to cross into the prompt. The alphabet is consonants only, which keeps labels short
|
||||
* and avoids accidentally spelling real words.
|
||||
*/
|
||||
|
||||
export const LABEL_ALPHABET = 'bcdfghjkmnpqrstvwxz';
|
||||
export const LABEL_LENGTH = 4;
|
||||
|
||||
export interface MintLabelsOptions {
|
||||
taken?: ReadonlySet<string>;
|
||||
rng?: () => number;
|
||||
}
|
||||
|
||||
/** Mint distinct four-consonant labels disjoint from any supplied label space. */
|
||||
export function mintLabels(count: number, options: MintLabelsOptions = {}): string[] {
|
||||
if (!Number.isSafeInteger(count) || count < 0) {
|
||||
throw new Error(`mintLabels: count must be a non-negative safe integer, received ${count}`);
|
||||
}
|
||||
|
||||
// `taken` reserves label strings that must not be minted (for one class, the producer IDs
|
||||
// themselves), so a minted label can never collide with a value already meaningful to the caller.
|
||||
const used = new Set(options.taken);
|
||||
const capacity = LABEL_ALPHABET.length ** LABEL_LENGTH;
|
||||
if (count + used.size > capacity) {
|
||||
throw new Error(`mintLabels: cannot mint ${count} labels; alphabet space is ${capacity}`);
|
||||
}
|
||||
|
||||
const rng = options.rng ?? Math.random;
|
||||
const labels: string[] = [];
|
||||
while (labels.length < count) {
|
||||
let label = '';
|
||||
for (let index = 0; index < LABEL_LENGTH; index++) {
|
||||
const sample = rng();
|
||||
if (!Number.isFinite(sample) || sample < 0 || sample >= 1) {
|
||||
throw new Error('mintLabels: rng must return a finite value in [0, 1)');
|
||||
}
|
||||
label += LABEL_ALPHABET[Math.floor(sample * LABEL_ALPHABET.length)];
|
||||
}
|
||||
if (used.has(label)) continue;
|
||||
used.add(label);
|
||||
labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// 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.
|
||||
|
||||
/** Validation and committed reads for the durable class-publication manifest. */
|
||||
|
||||
import { readCommittedFile } from '../../services/git-manager.js';
|
||||
import { ALL_RECONCILIATION_CLASSES, type ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import type { PublicationContract } from './contracts.js';
|
||||
import { mintTaskReferences } from './materialize-core.js';
|
||||
import { isProducerId, isTaskReference } from './refs.js';
|
||||
import { RECONCILIATION_SCHEMA_VERSION } from './schema-version.js';
|
||||
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const GIT_BLOB_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
||||
|
||||
/** One committed consumer file and the digest the manifest vouches for. */
|
||||
export interface ManifestConsumerFile {
|
||||
path: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
/** One stable task's producer lineage. OSS omits `novelty`. */
|
||||
export interface ManifestLineageEntry {
|
||||
primary: string;
|
||||
absorbed: string[];
|
||||
novelty?: 'new' | 'recurring';
|
||||
}
|
||||
|
||||
/** The schema-v1 durable completion marker for one class publication. */
|
||||
export interface PublicationManifest {
|
||||
session_id: string;
|
||||
vulnerability_class: ReconciliationClass;
|
||||
schema_version: 1;
|
||||
producer_queue: { path: string; blob_sha: string };
|
||||
consumer_files: ManifestConsumerFile[];
|
||||
input_digests: Array<{ artifactKind: string; sha256: string }>;
|
||||
lineage: Record<string, ManifestLineageEntry>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): boolean {
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
const actual = Object.keys(value);
|
||||
return required.every((key) => key in value) && actual.every((key) => allowed.has(key));
|
||||
}
|
||||
|
||||
function isSafeRelativePath(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
!value.startsWith('/') &&
|
||||
!value.startsWith('\\') &&
|
||||
!value.includes('\0') &&
|
||||
!value.split(/[\\/]/).some((segment) => segment === '' || segment === '.' || segment === '..')
|
||||
);
|
||||
}
|
||||
|
||||
function isProducerQueueIdentity(value: unknown): value is PublicationManifest['producer_queue'] {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['path', 'blob_sha'])) return false;
|
||||
return isSafeRelativePath(value.path) && typeof value.blob_sha === 'string' && GIT_BLOB_PATTERN.test(value.blob_sha);
|
||||
}
|
||||
|
||||
function isConsumerFile(value: unknown): value is ManifestConsumerFile {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['path', 'sha256'])) return false;
|
||||
return isSafeRelativePath(value.path) && typeof value.sha256 === 'string' && SHA256_PATTERN.test(value.sha256);
|
||||
}
|
||||
|
||||
function isInputDigest(value: unknown): value is PublicationManifest['input_digests'][number] {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['artifactKind', 'sha256'])) return false;
|
||||
return (
|
||||
typeof value.artifactKind === 'string' &&
|
||||
value.artifactKind.length > 0 &&
|
||||
typeof value.sha256 === 'string' &&
|
||||
SHA256_PATTERN.test(value.sha256)
|
||||
);
|
||||
}
|
||||
|
||||
function isProducerIdForClass(value: string, vulnerabilityClass: ReconciliationClass): boolean {
|
||||
return isProducerId(value, vulnerabilityClass, 'VULN') || isProducerId(value, vulnerabilityClass, 'SAST');
|
||||
}
|
||||
|
||||
function isLineageEntry(value: unknown, vulnerabilityClass: ReconciliationClass): value is ManifestLineageEntry {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['primary', 'absorbed'], ['novelty'])) return false;
|
||||
if (typeof value.primary !== 'string' || !isProducerIdForClass(value.primary, vulnerabilityClass)) return false;
|
||||
if (
|
||||
!Array.isArray(value.absorbed) ||
|
||||
!value.absorbed.every(
|
||||
(producerId) => typeof producerId === 'string' && isProducerIdForClass(producerId, vulnerabilityClass),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return value.novelty === undefined || value.novelty === 'new' || value.novelty === 'recurring';
|
||||
}
|
||||
|
||||
/** Whether a decoded value is a complete schema-v1 publication manifest. */
|
||||
export function isManifest(value: unknown): value is PublicationManifest {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasExactKeys(value, [
|
||||
'session_id',
|
||||
'vulnerability_class',
|
||||
'schema_version',
|
||||
'producer_queue',
|
||||
'consumer_files',
|
||||
'input_digests',
|
||||
'lineage',
|
||||
])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof value.session_id !== 'string' ||
|
||||
value.session_id.length === 0 ||
|
||||
!ALL_RECONCILIATION_CLASSES.includes(value.vulnerability_class as ReconciliationClass) ||
|
||||
value.schema_version !== RECONCILIATION_SCHEMA_VERSION ||
|
||||
!isProducerQueueIdentity(value.producer_queue) ||
|
||||
!Array.isArray(value.consumer_files) ||
|
||||
!value.consumer_files.every(isConsumerFile) ||
|
||||
!Array.isArray(value.input_digests) ||
|
||||
!value.input_digests.every(isInputDigest) ||
|
||||
!isRecord(value.lineage)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const vulnerabilityClass = value.vulnerability_class as ReconciliationClass;
|
||||
const consumerPaths = value.consumer_files.map((consumer) => consumer.path);
|
||||
if (new Set(consumerPaths).size !== consumerPaths.length) return false;
|
||||
|
||||
// A coherent publication is derived from exactly the three stage artifacts, one of each kind.
|
||||
// A different count or a repeated kind means the manifest was not built from a complete lineage.
|
||||
const inputKinds = value.input_digests.map((input) => input.artifactKind);
|
||||
if (inputKinds.length !== 3 || new Set(inputKinds).size !== inputKinds.length) return false;
|
||||
if (
|
||||
!['producer-observations', 'supplemental-observations', 'fixed-tasks'].every((kind) => inputKinds.includes(kind))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Lineage keys must be the dense minted references PREFIX-01..PREFIX-NN in order, and every
|
||||
// producer ID across all entries must be unique. This is the same task numbering the published
|
||||
// queue carries, so a manifest that renumbers or repeats a producer cannot pass.
|
||||
const lineageEntries = Object.entries(value.lineage);
|
||||
const expectedTaskReferences = mintTaskReferences(lineageEntries.length, vulnerabilityClass);
|
||||
const producerIds = new Set<string>();
|
||||
for (const [index, [taskReference, entry]] of lineageEntries.entries()) {
|
||||
if (
|
||||
!isTaskReference(taskReference, vulnerabilityClass) ||
|
||||
taskReference !== expectedTaskReferences[index] ||
|
||||
!isLineageEntry(entry, vulnerabilityClass)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const typedEntry = entry as ManifestLineageEntry;
|
||||
for (const producerId of [typedEntry.primary, ...typedEntry.absorbed]) {
|
||||
if (producerIds.has(producerId)) return false;
|
||||
producerIds.add(producerId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Classified outcome of reading a class manifest from Git `HEAD`. */
|
||||
export type ManifestRead =
|
||||
| { state: 'absent' }
|
||||
| { state: 'invalid'; reason: string }
|
||||
| { state: 'present'; manifest: PublicationManifest; contents: string };
|
||||
|
||||
/** Read and strictly validate one committed manifest. */
|
||||
export async function readPublishedManifest(
|
||||
deliverablesDirPath: string,
|
||||
manifestRelPath: string,
|
||||
): Promise<ManifestRead> {
|
||||
const read = await readCommittedFile(deliverablesDirPath, manifestRelPath);
|
||||
if (read.state === 'absent') return { state: 'absent' };
|
||||
if (read.state === 'corrupt') {
|
||||
return { state: 'invalid', reason: 'manifest object is unreadable' };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(read.contents);
|
||||
} catch {
|
||||
return { state: 'invalid', reason: 'manifest is not valid JSON' };
|
||||
}
|
||||
if (!isManifest(parsed)) {
|
||||
return { state: 'invalid', reason: 'manifest is truncated, malformed, or contains unexpected fields' };
|
||||
}
|
||||
return { state: 'present', manifest: parsed, contents: read.contents };
|
||||
}
|
||||
|
||||
function samePathSet(actual: readonly string[], expected: readonly string[]): boolean {
|
||||
if (actual.length !== expected.length) return false;
|
||||
const actualSet = new Set(actual);
|
||||
return actualSet.size === actual.length && expected.every((path) => actualSet.has(path));
|
||||
}
|
||||
|
||||
/** Whether a manifest exactly matches the expected OSS publication identity and path set. */
|
||||
export function isManifestCoherent(args: {
|
||||
manifest: PublicationManifest;
|
||||
sessionId: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
contract: PublicationContract;
|
||||
producerQueuePath: string;
|
||||
producerBlobSha?: string;
|
||||
}): boolean {
|
||||
const { manifest, sessionId, vulnerabilityClass, contract, producerQueuePath, producerBlobSha } = args;
|
||||
if (contract.publicationKind !== 'class-reconciliation') return false;
|
||||
if (contract.schemaVersion !== RECONCILIATION_SCHEMA_VERSION) return false;
|
||||
if (manifest.session_id !== sessionId) return false;
|
||||
if (manifest.vulnerability_class !== vulnerabilityClass) return false;
|
||||
if (manifest.schema_version !== contract.schemaVersion) return false;
|
||||
if (manifest.producer_queue.path !== producerQueuePath) return false;
|
||||
if (producerBlobSha !== undefined && manifest.producer_queue.blob_sha !== producerBlobSha) return false;
|
||||
return samePathSet(
|
||||
manifest.consumer_files.map((consumer) => consumer.path),
|
||||
contract.requiredOutputPaths,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// 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.
|
||||
|
||||
/** Pure deterministic collapse, ordering, and reference assignment for class tasks. */
|
||||
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import { ArtifactIntegrityError } from './artifact-store.js';
|
||||
import type {
|
||||
MergedObservation,
|
||||
PrimaryPreference,
|
||||
ReconciliationObservation,
|
||||
ReconciliationTask,
|
||||
} from './contracts.js';
|
||||
import { REF_PREFIX } from './refs.js';
|
||||
|
||||
const PRIORITY_ORDER: Readonly<Record<string, number>> = Object.freeze({ P1: 0, P2: 1, P3: 2 });
|
||||
const CONFIDENCE_ORDER: Readonly<Record<string, number>> = Object.freeze({ high: 0, medium: 1, low: 2 });
|
||||
const MISSING_PRIORITY_RANK = 2;
|
||||
const MISSING_CONFIDENCE_RANK = 2;
|
||||
|
||||
type PreTask = Omit<ReconciliationTask, 'ID'>;
|
||||
|
||||
/** Ordered tasks, complete lineage, and merged-member accounting. */
|
||||
export interface FixedTasks {
|
||||
tasks: ReconciliationTask[];
|
||||
observationToTask: Record<string, string>;
|
||||
mergedFromTotal: number;
|
||||
}
|
||||
|
||||
/** Mint dense references `PREFIX-01..PREFIX-NN`, continuing unpadded past 99. */
|
||||
export function mintTaskReferences(count: number, vulnerabilityClass: ReconciliationClass): string[] {
|
||||
const references: string[] = [];
|
||||
for (let index = 1; index <= count; index++) {
|
||||
references.push(`${REF_PREFIX[vulnerabilityClass]}-${String(index).padStart(2, '0')}`);
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
function rank(value: unknown, order: Readonly<Record<string, number>>, fallback: number): number {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
return order[value] ?? fallback;
|
||||
}
|
||||
|
||||
// The strongest priority or confidence across a merged group of observations wins for the task,
|
||||
// rather than the primary observation's own value. A weaker duplicate finding should not water
|
||||
// down a stronger signal one of the other producers already established for the same vulnerability.
|
||||
function strongest(
|
||||
members: readonly ReconciliationObservation[],
|
||||
field: 'priority' | 'confidence',
|
||||
order: Readonly<Record<string, number>>,
|
||||
): string | undefined {
|
||||
let best: string | undefined;
|
||||
let bestRank = Number.POSITIVE_INFINITY;
|
||||
for (const member of members) {
|
||||
const value = (member as unknown as Record<string, unknown>)[field];
|
||||
if (typeof value !== 'string') continue;
|
||||
const valueRank = rank(value, order, Number.POSITIVE_INFINITY);
|
||||
if (valueRank < bestRank) {
|
||||
best = value;
|
||||
bestRank = valueRank;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// A `preferred` member (a SAST producer) outranks a `default` one, so a group that pairs a SAST
|
||||
// finding with a vulnerability-analysis finding keeps the SAST observation as the task primary.
|
||||
function preferenceRank(preference: PrimaryPreference): number {
|
||||
return preference === 'preferred' ? 0 : 1;
|
||||
}
|
||||
|
||||
function primaryIndex(members: readonly ReconciliationObservation[]): number {
|
||||
if (members.length === 0) {
|
||||
throw new ArtifactIntegrityError('Cannot materialize an empty observation group');
|
||||
}
|
||||
let selected = 0;
|
||||
let selectedRank = preferenceRank(members[0]?.primary_preference ?? 'default');
|
||||
for (let index = 1; index < members.length; index++) {
|
||||
const member = members[index];
|
||||
if (member === undefined) continue;
|
||||
const memberRank = preferenceRank(member.primary_preference);
|
||||
if (memberRank < selectedRank) {
|
||||
selected = index;
|
||||
selectedRank = memberRank;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
// `primary_preference` has already done its job by the time a group reaches this function: it
|
||||
// picked the primary observation via `primaryIndex` above. Dropping it here, rather than carrying
|
||||
// it into the task, is what the `MaterializedProducerFields` type in contracts.ts enforces at
|
||||
// compile time; nothing downstream of materialization is allowed to see or re-derive this preference.
|
||||
function toMaterialized(observation: ReconciliationObservation): MergedObservation {
|
||||
const { primary_preference: _primaryPreference, ...materialized } = observation;
|
||||
return materialized as MergedObservation;
|
||||
}
|
||||
|
||||
/** Collapse one member group into a single pre-task: pick the primary, fold the rest as `merged_from`. */
|
||||
function buildTask(members: readonly ReconciliationObservation[]): PreTask {
|
||||
const selectedIndex = primaryIndex(members);
|
||||
const primary = members[selectedIndex];
|
||||
if (primary === undefined) {
|
||||
throw new ArtifactIntegrityError('Materialization selected a missing primary observation');
|
||||
}
|
||||
const merged = members.filter((_member, index) => index !== selectedIndex);
|
||||
const priority = strongest(members, 'priority', PRIORITY_ORDER);
|
||||
const confidence = strongest(members, 'confidence', CONFIDENCE_ORDER);
|
||||
|
||||
const task: Record<string, unknown> = { ...toMaterialized(primary) };
|
||||
if (priority !== undefined) task.priority = priority;
|
||||
if (confidence !== undefined) task.confidence = confidence;
|
||||
if (merged.length > 0) task.merged_from = merged.map(toMaterialized);
|
||||
return task as PreTask;
|
||||
}
|
||||
|
||||
function compareCodeUnits(first: string, second: string): number {
|
||||
if (first < second) return -1;
|
||||
if (first > second) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function taskField(task: PreTask, field: string): unknown {
|
||||
return (task as unknown as Record<string, unknown>)[field];
|
||||
}
|
||||
|
||||
// Total order over tasks: priority, then confidence, then producer ID as a final tie-break. The
|
||||
// producer-ID comparison guarantees no two tasks ever compare equal, so the sort is fully
|
||||
// deterministic and the dense reference assignment below is reproducible across retries.
|
||||
function compareTasks(first: PreTask, second: PreTask): number {
|
||||
const priorityDifference =
|
||||
rank(taskField(first, 'priority'), PRIORITY_ORDER, MISSING_PRIORITY_RANK) -
|
||||
rank(taskField(second, 'priority'), PRIORITY_ORDER, MISSING_PRIORITY_RANK);
|
||||
if (priorityDifference !== 0) return priorityDifference;
|
||||
|
||||
const confidenceDifference =
|
||||
rank(taskField(first, 'confidence'), CONFIDENCE_ORDER, MISSING_CONFIDENCE_RANK) -
|
||||
rank(taskField(second, 'confidence'), CONFIDENCE_ORDER, MISSING_CONFIDENCE_RANK);
|
||||
if (confidenceDifference !== 0) return confidenceDifference;
|
||||
|
||||
const firstProducer = taskField(first, 'producer_id');
|
||||
const secondProducer = taskField(second, 'producer_id');
|
||||
return compareCodeUnits(
|
||||
typeof firstProducer === 'string' ? firstProducer : '',
|
||||
typeof secondProducer === 'string' ? secondProducer : '',
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapse member groups, sort by the locked total order, and assign dense stable references. */
|
||||
export function buildFixedTasks(
|
||||
memberGroups: ReadonlyArray<readonly ReconciliationObservation[]>,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): FixedTasks {
|
||||
const preTasks = memberGroups.map(buildTask);
|
||||
preTasks.sort(compareTasks);
|
||||
|
||||
const references = mintTaskReferences(preTasks.length, vulnerabilityClass);
|
||||
const tasks = preTasks.map(
|
||||
(task, index) =>
|
||||
({
|
||||
...(task as unknown as Record<string, unknown>),
|
||||
ID: references[index] as string,
|
||||
}) as ReconciliationTask,
|
||||
);
|
||||
|
||||
const observationToTask: Record<string, string> = Object.create(null);
|
||||
let mergedFromTotal = 0;
|
||||
for (const task of tasks) {
|
||||
observationToTask[task.producer_id] = task.ID;
|
||||
for (const member of task.merged_from ?? []) {
|
||||
observationToTask[member.producer_id] = task.ID;
|
||||
mergedFromTotal++;
|
||||
}
|
||||
}
|
||||
return { tasks, observationToTask, mergedFromTotal };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// 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.
|
||||
|
||||
/** Revalidate stage artifacts and materialize every observation into one fixed task. */
|
||||
|
||||
import { Check } from 'typebox/value';
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import { classEntrySchema, QUEUE_ENTRY_FIELD_NAMES } from '../queue-schemas.js';
|
||||
import { ArtifactIntegrityError, artifactInputsMatch, readArtifact, writeArtifact } from './artifact-store.js';
|
||||
import type { ArtifactInputDigest, ArtifactRef, ReconciliationObservation } from './contracts.js';
|
||||
import { buildFixedTasks } from './materialize-core.js';
|
||||
import { combineObservations } from './observations.js';
|
||||
import { isProducerId } from './refs.js';
|
||||
import { type FixedTasksBody, type FormResult, type MaterializeResult, SINGLETON_FALLBACK } from './stage-contracts.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateSastLocation(value: unknown): boolean {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.file === 'string' &&
|
||||
value.file.length > 0 &&
|
||||
Number.isSafeInteger(value.line) &&
|
||||
(value.line as number) > 0 &&
|
||||
Number.isSafeInteger(value.column) &&
|
||||
(value.column as number) > 0 &&
|
||||
typeof value.rule_id === 'string' &&
|
||||
value.rule_id.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
// Defense in depth: an earlier stage already validated this observation's shape, but materialization
|
||||
// is the last stop before publication, so it revalidates independently rather than trusting an
|
||||
// artifact read back off disk. Re-derives the evidence subset and schema-checks it, then confirms no
|
||||
// field outside the declared class/source contract survived (including any internal key a bug in an
|
||||
// earlier stage might have let through).
|
||||
function validateEvidenceShape(observation: ReconciliationObservation, vulnerabilityClass: ReconciliationClass): void {
|
||||
const record = observation as unknown as Record<string, unknown>;
|
||||
const evidence: Record<string, unknown> = { ID: observation.producer_id };
|
||||
for (const key of QUEUE_ENTRY_FIELD_NAMES[vulnerabilityClass]) {
|
||||
if (key !== 'ID' && key in record) evidence[key] = record[key];
|
||||
}
|
||||
if (!Check(classEntrySchema(vulnerabilityClass), evidence)) {
|
||||
throw new ArtifactIntegrityError('Observation evidence does not match its declared class schema');
|
||||
}
|
||||
|
||||
const allowed = new Set<string>([
|
||||
...QUEUE_ENTRY_FIELD_NAMES[vulnerabilityClass].filter((key) => key !== 'ID'),
|
||||
'producer_id',
|
||||
'scan_source',
|
||||
'primary_preference',
|
||||
...(observation.scan_source === 'sast' ? ['priority', 'sast_source_location'] : []),
|
||||
]);
|
||||
if (Object.keys(record).some((key) => !allowed.has(key))) {
|
||||
throw new ArtifactIntegrityError('Observation contains fields outside its class/source contract');
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconfirm producer identity and evidence shape match the observation's declared source and class. */
|
||||
function validateObservation(observation: ReconciliationObservation, vulnerabilityClass: ReconciliationClass): void {
|
||||
validateEvidenceShape(observation, vulnerabilityClass);
|
||||
if (observation.scan_source === 'vulnerability_analysis') {
|
||||
if (
|
||||
observation.primary_preference !== 'default' ||
|
||||
!isProducerId(observation.producer_id, vulnerabilityClass, 'VULN')
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Vulnerability-analysis observation has an invalid class/source identity');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (observation.scan_source !== 'sast') {
|
||||
throw new ArtifactIntegrityError('Observation has an unknown producer source');
|
||||
}
|
||||
if (
|
||||
observation.primary_preference !== 'preferred' ||
|
||||
!isProducerId(observation.producer_id, vulnerabilityClass, 'SAST') ||
|
||||
!['P1', 'P2', 'P3'].includes(observation.priority) ||
|
||||
!validateSastLocation(observation.sast_source_location)
|
||||
) {
|
||||
throw new ArtifactIntegrityError('SAST observation has an invalid class/source identity or annotation');
|
||||
}
|
||||
}
|
||||
|
||||
function partitionMembers(
|
||||
observations: readonly ReconciliationObservation[],
|
||||
groups: readonly unknown[],
|
||||
): ReconciliationObservation[][] {
|
||||
const byProducerId = new Map(observations.map((observation) => [observation.producer_id, observation]));
|
||||
const assigned = new Set<string>();
|
||||
const memberGroups: ReconciliationObservation[][] = [];
|
||||
|
||||
for (const rawGroup of groups) {
|
||||
if (
|
||||
!isRecord(rawGroup) ||
|
||||
Object.keys(rawGroup).some((key) => key !== 'producer_ids' && key !== 'reasoning') ||
|
||||
!Array.isArray(rawGroup.producer_ids) ||
|
||||
!rawGroup.producer_ids.every((producerId) => typeof producerId === 'string') ||
|
||||
rawGroup.producer_ids.length < 2 ||
|
||||
typeof rawGroup.reasoning !== 'string' ||
|
||||
rawGroup.reasoning.length === 0
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Accepted task formation contains a malformed group');
|
||||
}
|
||||
const group = rawGroup as { producer_ids: string[]; reasoning: string };
|
||||
const members: ReconciliationObservation[] = [];
|
||||
const groupIds = new Set<string>();
|
||||
for (const producerId of group.producer_ids) {
|
||||
const observation = byProducerId.get(producerId);
|
||||
if (observation === undefined || groupIds.has(producerId) || assigned.has(producerId)) {
|
||||
throw new ArtifactIntegrityError('Accepted task formation has unknown, duplicate, or reused membership');
|
||||
}
|
||||
groupIds.add(producerId);
|
||||
members.push(observation);
|
||||
}
|
||||
for (const producerId of groupIds) assigned.add(producerId);
|
||||
memberGroups.push(members);
|
||||
}
|
||||
|
||||
// Every observation the model did not place into a group becomes its own singleton task. This is
|
||||
// also exactly the whole-set result when task formation is skipped, so the fallback path and the
|
||||
// model path converge on the same shape: one task per unmerged observation.
|
||||
for (const observation of observations) {
|
||||
if (!assigned.has(observation.producer_id)) memberGroups.push([observation]);
|
||||
}
|
||||
return memberGroups;
|
||||
}
|
||||
|
||||
function assertRefClass(ref: ArtifactRef, vulnerabilityClass: ReconciliationClass): void {
|
||||
if (ref.vulnerabilityClass !== vulnerabilityClass) {
|
||||
throw new ArtifactIntegrityError('Artifact reference crosses the declared class boundary');
|
||||
}
|
||||
}
|
||||
|
||||
export interface MaterializeClassExploitTasksArgs {
|
||||
sessionId: string;
|
||||
workspacesDir?: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
producerRef: ArtifactRef<'producer-observations'>;
|
||||
supplementalRef: ArtifactRef<'supplemental-observations'>;
|
||||
form: FormResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize one class and publish its content-addressed `03-fixed-tasks` artifact.
|
||||
*
|
||||
* Combines the producer and supplemental observations, applies the accepted formation groups (or
|
||||
* treats every observation as its own singleton task when formation fell back), collapses each
|
||||
* group into one task via `buildFixedTasks`, and confirms the resulting observation-to-task map
|
||||
* covers every observation exactly once before returning.
|
||||
*/
|
||||
export async function materializeClassExploitTasks(args: MaterializeClassExploitTasksArgs): Promise<MaterializeResult> {
|
||||
assertRefClass(args.producerRef, args.vulnerabilityClass);
|
||||
assertRefClass(args.supplementalRef, args.vulnerabilityClass);
|
||||
if (args.form !== SINGLETON_FALLBACK) assertRefClass(args.form.ref, args.vulnerabilityClass);
|
||||
|
||||
const producer = await readArtifact(args.producerRef, args.sessionId, args.workspacesDir);
|
||||
const supplemental = await readArtifact(args.supplementalRef, args.sessionId, args.workspacesDir);
|
||||
if (!Array.isArray(producer.observations) || !Array.isArray(supplemental.observations)) {
|
||||
throw new ArtifactIntegrityError('Observation artifact body is truncated or malformed');
|
||||
}
|
||||
const observations = combineObservations(producer.observations, supplemental.observations);
|
||||
for (const observation of observations) validateObservation(observation, args.vulnerabilityClass);
|
||||
|
||||
const observationInputs: ArtifactInputDigest[] = [
|
||||
{ artifactKind: 'producer-observations', sha256: args.producerRef.sha256 },
|
||||
{ artifactKind: 'supplemental-observations', sha256: args.supplementalRef.sha256 },
|
||||
];
|
||||
// With the singleton fallback there are no groups, so every observation materializes alone. With a
|
||||
// real formation artifact, its lineage must name these exact observation digests, or it grouped a
|
||||
// different observation set than the one being materialized here.
|
||||
let groups: readonly unknown[] = [];
|
||||
if (args.form !== SINGLETON_FALLBACK) {
|
||||
if (!artifactInputsMatch(args.form.ref.inputs, observationInputs)) {
|
||||
throw new ArtifactIntegrityError('Task-formation lineage does not match the supplied observations');
|
||||
}
|
||||
const formation = await readArtifact(args.form.ref, args.sessionId, args.workspacesDir);
|
||||
if (!Array.isArray(formation.groups)) {
|
||||
throw new ArtifactIntegrityError('Accepted task formation has no groups array');
|
||||
}
|
||||
groups = formation.groups;
|
||||
}
|
||||
|
||||
const memberGroups = partitionMembers(observations, groups);
|
||||
const fixed = buildFixedTasks(memberGroups, args.vulnerabilityClass);
|
||||
if (Object.keys(fixed.observationToTask).length !== observations.length) {
|
||||
throw new ArtifactIntegrityError('Observation-to-task map is incomplete');
|
||||
}
|
||||
|
||||
const inputs: ArtifactInputDigest[] = [...observationInputs];
|
||||
if (args.form !== SINGLETON_FALLBACK) {
|
||||
inputs.push({ artifactKind: 'task-formation', sha256: args.form.ref.sha256 });
|
||||
}
|
||||
const body: FixedTasksBody = {
|
||||
tasks: fixed.tasks,
|
||||
observation_to_task: fixed.observationToTask,
|
||||
};
|
||||
const ref = await writeArtifact({
|
||||
sessionId: args.sessionId,
|
||||
...(args.workspacesDir !== undefined ? { workspacesDir: args.workspacesDir } : {}),
|
||||
artifactKind: 'fixed-tasks',
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
body,
|
||||
inputs,
|
||||
counts: { tasks: fixed.tasks.length, merged_from_total: fixed.mergedFromTotal },
|
||||
});
|
||||
return { ref };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/** Positive observation projection used at the task-formation model boundary. */
|
||||
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import { QUEUE_ENTRY_FIELD_NAMES } from '../queue-schemas.js';
|
||||
import type { ReconciliationObservation } from './contracts.js';
|
||||
import type { ObservationView } from './stage-contracts.js';
|
||||
|
||||
const BOOLEAN_EVIDENCE_KEY = 'externally_exploitable';
|
||||
const PRODUCER_ID_REDACTION = '[redacted]';
|
||||
|
||||
function redactString(value: string, producerIds: readonly string[]): string {
|
||||
let redacted = value;
|
||||
for (const producerId of producerIds) {
|
||||
if (redacted.includes(producerId)) {
|
||||
redacted = redacted.split(producerId).join(PRODUCER_ID_REDACTION);
|
||||
}
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
/** Redact producer-ID tokens from arbitrary free text. */
|
||||
export function redactProducerIds(value: string, producerIds: Iterable<string>): string {
|
||||
const sorted = [...new Set(producerIds)].filter((id) => id.length > 0).sort((a, b) => b.length - a.length);
|
||||
return redactString(value, sorted);
|
||||
}
|
||||
|
||||
function redactDeep(value: unknown, producerIds: readonly string[]): unknown {
|
||||
if (typeof value === 'string') return redactString(value, producerIds);
|
||||
if (Array.isArray(value)) return value.map((item) => redactDeep(item, producerIds));
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
output[key] = redactDeep(item, producerIds);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Rebuilds the location from scratch rather than passing the stored value through, so a malformed
|
||||
// or tampered `sast_source_location` on the underlying observation cannot cross into the model view
|
||||
// unnoticed; anything that fails this shape check (including a `rule_id` that isn't a real CWE
|
||||
// identifier) is silently dropped from the view rather than surfaced as-is.
|
||||
function rebuildSastSourceLocation(value: unknown): ObservationView['sast_source_location'] {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const location = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof location.file === 'string' &&
|
||||
location.file.length > 0 &&
|
||||
Number.isSafeInteger(location.line) &&
|
||||
(location.line as number) > 0 &&
|
||||
Number.isSafeInteger(location.column) &&
|
||||
(location.column as number) > 0 &&
|
||||
typeof location.rule_id === 'string' &&
|
||||
/^CWE-[1-9][0-9]*$/.test(location.rule_id)
|
||||
) {
|
||||
return {
|
||||
file: location.file,
|
||||
line: location.line as number,
|
||||
column: location.column as number,
|
||||
rule_id: location.rule_id,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Return producer IDs still present in a fully serialized model context. */
|
||||
export function findLeakedProducerIds(modelContext: string, producerIds: Iterable<string>): string[] {
|
||||
const leaked: string[] = [];
|
||||
for (const producerId of new Set(producerIds)) {
|
||||
if (producerId.length > 0 && modelContext.includes(producerId)) leaked.push(producerId);
|
||||
}
|
||||
return leaked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild one model-visible observation from declared primitive evidence only.
|
||||
* Internal keys and non-primitive evidence never cross this boundary.
|
||||
*/
|
||||
export function toObservationView(
|
||||
observation: ReconciliationObservation,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
producerIds: Iterable<string>,
|
||||
): ObservationView {
|
||||
const source = observation as unknown as Record<string, unknown>;
|
||||
const view: Record<string, unknown> = {};
|
||||
|
||||
for (const key of QUEUE_ENTRY_FIELD_NAMES[vulnerabilityClass]) {
|
||||
if (key === 'ID') continue;
|
||||
const value = source[key];
|
||||
if (key === BOOLEAN_EVIDENCE_KEY) {
|
||||
if (typeof value === 'boolean') view[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'string') view[key] = value;
|
||||
}
|
||||
|
||||
if (source.scan_source === 'vulnerability_analysis' || source.scan_source === 'sast') {
|
||||
view.scan_source = source.scan_source;
|
||||
}
|
||||
if (source.priority === 'P1' || source.priority === 'P2' || source.priority === 'P3') {
|
||||
view.priority = source.priority;
|
||||
}
|
||||
const location = rebuildSastSourceLocation(source.sast_source_location);
|
||||
if (location !== undefined) view.sast_source_location = location;
|
||||
|
||||
const sortedIds = [...new Set(producerIds)].filter((id) => id.length > 0).sort((a, b) => b.length - a.length);
|
||||
return redactDeep(view, sortedIds) as ObservationView;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Utilities shared by task formation and materialization observation intake. */
|
||||
|
||||
import { ArtifactIntegrityError } from './artifact-store.js';
|
||||
import type { ReconciliationObservation } from './contracts.js';
|
||||
|
||||
/** Concatenate producer and supplemental observations while enforcing one global producer-ID set. */
|
||||
export function combineObservations(
|
||||
producer: readonly ReconciliationObservation[],
|
||||
supplemental: readonly ReconciliationObservation[],
|
||||
): ReconciliationObservation[] {
|
||||
const combined = [...producer, ...supplemental];
|
||||
const seen = new Set<string>();
|
||||
for (const observation of combined) {
|
||||
if (seen.has(observation.producer_id)) {
|
||||
throw new ArtifactIntegrityError('Reconciliation observations contain a duplicate producer identifier');
|
||||
}
|
||||
seen.add(observation.producer_id);
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// 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.
|
||||
|
||||
/** Admit committed producer observations or a complete existing class publication. */
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Check } from 'typebox/value';
|
||||
import {
|
||||
blobShaFromHead,
|
||||
readCommittedFile,
|
||||
restorePathsFromHead,
|
||||
withGitRepoLock,
|
||||
} from '../../services/git-manager.js';
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import { classEntrySchema, QUEUE_ENTRY_FIELD_NAMES } from '../queue-schemas.js';
|
||||
import {
|
||||
ArtifactIntegrityError,
|
||||
PublicationConflictError,
|
||||
ReconciliationError,
|
||||
ReconciliationIoError,
|
||||
writeArtifact,
|
||||
} from './artifact-store.js';
|
||||
import type { PublicationContract, ReconciliationObservation } from './contracts.js';
|
||||
import { isManifestCoherent, type PublicationManifest, readPublishedManifest } from './manifest.js';
|
||||
import { isProducerId, isTaskReference } from './refs.js';
|
||||
import type { PrepareResult, ProducerObservationsBody } from './stage-contracts.js';
|
||||
|
||||
// Duplicated from the identical set in publish.ts, which guards the fresh-publish path; this copy
|
||||
// guards the lost-acknowledgement repair path below, where an already-published queue is read back
|
||||
// and re-verified before being trusted. Both sets must list the same internal-only keys, or a key
|
||||
// added to only one path could round-trip a leaked queue back into "coherent" on the other.
|
||||
const FORBIDDEN_PUBLISHED_KEYS: ReadonlySet<string> = new Set([
|
||||
'producer_id',
|
||||
'primary_preference',
|
||||
'observation_key',
|
||||
'novelty',
|
||||
'_sastId',
|
||||
'_sast_id',
|
||||
'repository_id',
|
||||
'scan_run_id',
|
||||
]);
|
||||
|
||||
function sha256Text(contents: string): string {
|
||||
return createHash('sha256').update(contents, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
// Same defense as `preflightSymlinks` in publish.ts: a symlinked destination could redirect a write
|
||||
// (here, the restore step below) outside the deliverables directory, so any symlink found is a
|
||||
// conflict rather than something to write through.
|
||||
async function rejectSymlinkDestinations(deliverablesDir: string, relativePaths: readonly string[]): Promise<void> {
|
||||
for (const relativePath of relativePaths) {
|
||||
try {
|
||||
const stat = await lstat(path.join(deliverablesDir, relativePath));
|
||||
if (stat.isSymbolicLink()) throw new PublicationConflictError('Refusing to repair a publication symlink');
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) continue;
|
||||
if (error instanceof ReconciliationError) throw error;
|
||||
throw new ReconciliationIoError('Unable to inspect a class publication destination');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The class-owned producer and published queue path. */
|
||||
export function exploitationQueuePath(vulnerabilityClass: ReconciliationClass): string {
|
||||
return `${vulnerabilityClass}_exploitation_queue.json`;
|
||||
}
|
||||
|
||||
/** The class-owned durable completion-marker path. */
|
||||
export function reconciliationManifestPath(vulnerabilityClass: ReconciliationClass): string {
|
||||
return `${vulnerabilityClass}_reconciliation_manifest.json`;
|
||||
}
|
||||
|
||||
/** The conditional standalone SAST-provenance path. */
|
||||
export function sastProvenancePath(vulnerabilityClass: ReconciliationClass): string {
|
||||
return `sast_provenance_${vulnerabilityClass}.json`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// A raw producer queue carries producer IDs and no merge structure. A task reference or a
|
||||
// `merged_from` array means the queue was already normalized by a prior publication whose manifest
|
||||
// is now missing, which is an incoherent state to be reported rather than reprocessed.
|
||||
function looksNormalized(entry: Record<string, unknown>, vulnerabilityClass: ReconciliationClass): boolean {
|
||||
const id = entry.ID;
|
||||
return (typeof id === 'string' && isTaskReference(id, vulnerabilityClass)) || Array.isArray(entry.merged_from);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and strictly validate a committed producer queue before reconciliation ever touches it.
|
||||
*
|
||||
* Every entry must match its class's declared evidence schema and carry an ID in the VULN producer
|
||||
* namespace for this exact class; a queue that already looks normalized (see `looksNormalized`) or
|
||||
* carries a duplicate ID is rejected outright. The producer IDs minted or validated here are
|
||||
* internal identity: they exist to let reconciliation reason about and dedupe observations, and are
|
||||
* scrubbed before anything derived from this queue is published.
|
||||
*/
|
||||
function parseProducerQueue(raw: string, vulnerabilityClass: ReconciliationClass): Record<string, unknown>[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new ArtifactIntegrityError('Producer queue is not valid JSON');
|
||||
}
|
||||
if (!isRecord(parsed) || !Array.isArray(parsed.vulnerabilities)) {
|
||||
throw new ArtifactIntegrityError('Producer queue has no vulnerabilities array');
|
||||
}
|
||||
|
||||
const schema = classEntrySchema(vulnerabilityClass);
|
||||
const seenIds = new Set<string>();
|
||||
for (const entry of parsed.vulnerabilities) {
|
||||
if (!isRecord(entry) || typeof entry.ID !== 'string') {
|
||||
throw new ArtifactIntegrityError('Producer queue entry is missing a string ID');
|
||||
}
|
||||
if (looksNormalized(entry, vulnerabilityClass)) {
|
||||
throw new PublicationConflictError('Normalized queue in HEAD has no coherent publication manifest');
|
||||
}
|
||||
if (!Check(schema, entry)) {
|
||||
throw new ArtifactIntegrityError(`Producer queue entry does not match the ${vulnerabilityClass} schema`);
|
||||
}
|
||||
if (!isProducerId(entry.ID, vulnerabilityClass, 'VULN')) {
|
||||
throw new ArtifactIntegrityError('Producer queue entry is outside its declared class/source namespace');
|
||||
}
|
||||
if (seenIds.has(entry.ID)) {
|
||||
throw new ArtifactIntegrityError('Producer queue contains a duplicate producer identifier');
|
||||
}
|
||||
seenIds.add(entry.ID);
|
||||
}
|
||||
return parsed.vulnerabilities as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
// Every producer-queue (pentest) observation is stamped `primary_preference: 'default'`, the losing
|
||||
// side of the dedupe contract: if this observation is later merged with a SAST observation for the
|
||||
// same vulnerability, the SAST evidence becomes the task's primary record instead of this one.
|
||||
function toObservation(entry: Record<string, unknown>, evidenceKeys: readonly string[]): ReconciliationObservation {
|
||||
const evidence: Record<string, unknown> = {};
|
||||
for (const key of evidenceKeys) {
|
||||
if (key in entry) evidence[key] = entry[key];
|
||||
}
|
||||
return {
|
||||
...evidence,
|
||||
producer_id: entry.ID as string,
|
||||
scan_source: 'vulnerability_analysis',
|
||||
primary_preference: 'default',
|
||||
} as ReconciliationObservation;
|
||||
}
|
||||
|
||||
async function verifyCommittedConsumers(
|
||||
deliverablesDir: string,
|
||||
consumerFiles: ReadonlyArray<{ path: string; sha256: string }>,
|
||||
): Promise<Map<string, string>> {
|
||||
const contentsByPath = new Map<string, string>();
|
||||
for (const consumer of consumerFiles) {
|
||||
const committed = await readCommittedFile(deliverablesDir, consumer.path);
|
||||
if (committed.state !== 'present') {
|
||||
throw new PublicationConflictError('Existing class publication is missing a committed consumer');
|
||||
}
|
||||
if (sha256Text(committed.contents) !== consumer.sha256) {
|
||||
throw new PublicationConflictError('Existing class publication consumer does not match its manifest');
|
||||
}
|
||||
contentsByPath.set(consumer.path, committed.contents);
|
||||
}
|
||||
return contentsByPath;
|
||||
}
|
||||
|
||||
// Used only on the repair path below, where a manifest already exists in HEAD and the previously
|
||||
// published queue is being read back rather than freshly built. Even a publication from a prior run
|
||||
// gets this same forbidden-key check before it is trusted and adopted.
|
||||
function containsForbiddenPublishedKey(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(containsForbiddenPublishedKey);
|
||||
if (!isRecord(value)) return false;
|
||||
return Object.entries(value).some(
|
||||
([key, entry]) => FORBIDDEN_PUBLISHED_KEYS.has(key) || containsForbiddenPublishedKey(entry),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-verify the internal-identity boundary on a previously published queue before adopting it.
|
||||
*
|
||||
* A manifest existing in HEAD means some earlier run already published this class, but that
|
||||
* publication is only ever restored here, not blindly trusted: this confirms the queue's task IDs
|
||||
* match the manifest's lineage exactly, that no forbidden internal key survived, and that no
|
||||
* producer-ID token appears anywhere in the serialized queue. The lost-acknowledgement repair path
|
||||
* is a second place a boundary-violating queue could otherwise slip through, so it gets the same
|
||||
* fail-closed check as a fresh publish.
|
||||
*/
|
||||
function verifyPublishedQueue(
|
||||
contents: string,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
lineage: Record<string, { primary: string; absorbed: string[] }>,
|
||||
): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(contents);
|
||||
} catch {
|
||||
throw new PublicationConflictError('Existing published queue is not valid JSON');
|
||||
}
|
||||
if (!isRecord(parsed) || Object.keys(parsed).length !== 1 || !Array.isArray(parsed.vulnerabilities)) {
|
||||
throw new PublicationConflictError('Existing published queue does not have the canonical envelope');
|
||||
}
|
||||
|
||||
const queueTaskIds: string[] = [];
|
||||
for (const task of parsed.vulnerabilities) {
|
||||
if (!isRecord(task) || typeof task.ID !== 'string' || !isTaskReference(task.ID, vulnerabilityClass)) {
|
||||
throw new PublicationConflictError('Existing published queue contains an invalid task reference');
|
||||
}
|
||||
queueTaskIds.push(task.ID);
|
||||
}
|
||||
const lineageTaskIds = Object.keys(lineage);
|
||||
if (
|
||||
queueTaskIds.length !== lineageTaskIds.length ||
|
||||
queueTaskIds.some((taskId, index) => taskId !== lineageTaskIds[index])
|
||||
) {
|
||||
throw new PublicationConflictError('Existing published queue and manifest lineage disagree');
|
||||
}
|
||||
if (containsForbiddenPublishedKey(parsed)) {
|
||||
throw new PublicationConflictError('Existing published queue retains an internal producer-only key');
|
||||
}
|
||||
const serialized = JSON.stringify(parsed);
|
||||
const producerIds = Object.values(lineage).flatMap((entry) => [entry.primary, ...entry.absorbed]);
|
||||
if (producerIds.some((producerId) => serialized.includes(producerId))) {
|
||||
throw new PublicationConflictError('Existing published queue retains an internal producer identifier');
|
||||
}
|
||||
}
|
||||
|
||||
function samePathSet(actual: readonly string[], expected: readonly string[]): boolean {
|
||||
if (actual.length !== expected.length) return false;
|
||||
const actualPaths = new Set(actual);
|
||||
return actualPaths.size === actual.length && expected.every((expectedPath) => actualPaths.has(expectedPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which optional output shape the committed manifest actually published.
|
||||
*
|
||||
* The standalone SAST-provenance file is the one publication member that legitimately differs
|
||||
* between runs: whether the static-analysis stage produced SARIF decides it. That is a property of
|
||||
* the run that published, not of the run resuming, so on the repair path the committed manifest is
|
||||
* the authority on which shape to expect — otherwise a resume whose SARIF outcome flipped would
|
||||
* conflict with an otherwise coherent publication. Only the two shapes a class can legally publish
|
||||
* are admitted; any other path set is handed back as this run's own contract and rejected by
|
||||
* `isManifestCoherent`.
|
||||
*/
|
||||
function contractForCommittedShape(
|
||||
contract: PublicationContract,
|
||||
manifest: PublicationManifest,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): PublicationContract {
|
||||
const committedPaths = manifest.consumer_files.map((consumer) => consumer.path);
|
||||
const withoutProvenance = [exploitationQueuePath(vulnerabilityClass)];
|
||||
const withProvenance = [...withoutProvenance, sastProvenancePath(vulnerabilityClass)];
|
||||
for (const shape of [withoutProvenance, withProvenance]) {
|
||||
if (samePathSet(committedPaths, shape)) return { ...contract, requiredOutputPaths: shape };
|
||||
}
|
||||
return contract;
|
||||
}
|
||||
|
||||
export interface PrepareClassReconciliationArgs {
|
||||
deliverablesDir: string;
|
||||
sessionId: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
contract: PublicationContract;
|
||||
workspacesDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare one class from committed `HEAD`, repairing an existing coherent publication when present.
|
||||
*
|
||||
* Runs entirely inside the Git critical section. Three outcomes: a coherent published manifest is
|
||||
* restored from HEAD and reported as `already_published` (the lost-acknowledgement repair path); a
|
||||
* present-but-incoherent or corrupt manifest is a hard conflict; otherwise the committed producer
|
||||
* queue is parsed and written as the first content-addressed artifact and reported as `pending`.
|
||||
*/
|
||||
export async function prepareClassReconciliation(args: PrepareClassReconciliationArgs): Promise<PrepareResult> {
|
||||
const queuePath = exploitationQueuePath(args.vulnerabilityClass);
|
||||
const manifestPath = reconciliationManifestPath(args.vulnerabilityClass);
|
||||
if (args.contract.manifestPath !== manifestPath) {
|
||||
throw new ArtifactIntegrityError('Publication contract names the wrong class manifest');
|
||||
}
|
||||
|
||||
return withGitRepoLock(async (): Promise<PrepareResult> => {
|
||||
const manifestRead = await readPublishedManifest(args.deliverablesDir, manifestPath);
|
||||
if (manifestRead.state === 'invalid') {
|
||||
throw new PublicationConflictError(`Corrupt class manifest in HEAD: ${manifestRead.reason}`);
|
||||
}
|
||||
|
||||
if (manifestRead.state === 'present') {
|
||||
const committedContract = contractForCommittedShape(
|
||||
args.contract,
|
||||
manifestRead.manifest,
|
||||
args.vulnerabilityClass,
|
||||
);
|
||||
if (
|
||||
!isManifestCoherent({
|
||||
manifest: manifestRead.manifest,
|
||||
sessionId: args.sessionId,
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
contract: committedContract,
|
||||
producerQueuePath: queuePath,
|
||||
})
|
||||
) {
|
||||
throw new PublicationConflictError('Class manifest does not cohere with the publication contract');
|
||||
}
|
||||
// Mirrors the same guard on the fresh-publish path: when the publication being repaired
|
||||
// declares no standalone provenance, a provenance file in HEAD is residue from an interrupted
|
||||
// publish that no manifest vouches for, so it is a conflict rather than something to adopt.
|
||||
const provenancePath = sastProvenancePath(args.vulnerabilityClass);
|
||||
if (!committedContract.requiredOutputPaths.includes(provenancePath)) {
|
||||
const unvouchedProvenance = await readCommittedFile(args.deliverablesDir, provenancePath);
|
||||
if (unvouchedProvenance.state !== 'absent') {
|
||||
throw new PublicationConflictError('Existing publication has provenance outside its exact manifest path set');
|
||||
}
|
||||
}
|
||||
const consumerContents = await verifyCommittedConsumers(
|
||||
args.deliverablesDir,
|
||||
manifestRead.manifest.consumer_files,
|
||||
);
|
||||
const publishedQueueContents = consumerContents.get(queuePath);
|
||||
if (publishedQueueContents === undefined) {
|
||||
throw new PublicationConflictError('Existing class publication manifest omits its queue consumer');
|
||||
}
|
||||
verifyPublishedQueue(publishedQueueContents, args.vulnerabilityClass, manifestRead.manifest.lineage);
|
||||
await rejectSymlinkDestinations(args.deliverablesDir, [...committedContract.requiredOutputPaths, manifestPath]);
|
||||
await restorePathsFromHead(args.deliverablesDir, [...committedContract.requiredOutputPaths, manifestPath]);
|
||||
return { outcome: 'already_published', manifestSha256: sha256Text(manifestRead.contents) };
|
||||
}
|
||||
|
||||
const unexpectedProvenance = await readCommittedFile(
|
||||
args.deliverablesDir,
|
||||
sastProvenancePath(args.vulnerabilityClass),
|
||||
);
|
||||
if (unexpectedProvenance.state !== 'absent') {
|
||||
throw new PublicationConflictError('Pre-manifest class state contains standalone provenance');
|
||||
}
|
||||
|
||||
const queueRead = await readCommittedFile(args.deliverablesDir, queuePath);
|
||||
if (queueRead.state === 'absent') {
|
||||
throw new PublicationConflictError('Producer queue is not committed in HEAD');
|
||||
}
|
||||
if (queueRead.state === 'corrupt') {
|
||||
throw new ArtifactIntegrityError('Producer queue is committed but unreadable');
|
||||
}
|
||||
const blobSha = await blobShaFromHead(args.deliverablesDir, queuePath);
|
||||
if (blobSha.state !== 'present') {
|
||||
throw new ArtifactIntegrityError('Producer queue has no committed blob identity');
|
||||
}
|
||||
|
||||
const entries = parseProducerQueue(queueRead.contents, args.vulnerabilityClass);
|
||||
const evidenceKeys = QUEUE_ENTRY_FIELD_NAMES[args.vulnerabilityClass].filter((key) => key !== 'ID');
|
||||
const observations = entries.map((entry) => toObservation(entry, evidenceKeys));
|
||||
const body: ProducerObservationsBody = {
|
||||
observations,
|
||||
producer_queue: {
|
||||
path: queuePath,
|
||||
blob_sha: blobSha.sha,
|
||||
digest: sha256Text(queueRead.contents),
|
||||
},
|
||||
};
|
||||
const ref = await writeArtifact({
|
||||
sessionId: args.sessionId,
|
||||
...(args.workspacesDir !== undefined ? { workspacesDir: args.workspacesDir } : {}),
|
||||
artifactKind: 'producer-observations',
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
body,
|
||||
inputs: [],
|
||||
counts: { observations: observations.length },
|
||||
});
|
||||
return { outcome: 'pending', ref };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
// 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 OSS queue shaping and lost-acknowledgement-safe class publication. */
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
blobShaFromHead,
|
||||
commitExactPaths,
|
||||
ExactPathCommitMismatchError,
|
||||
lastCommitForPathAtHead,
|
||||
readCommittedFile,
|
||||
restorePathsFromHead,
|
||||
withGitRepoLock,
|
||||
} from '../../services/git-manager.js';
|
||||
import type { ActivityLogger } from '../../types/activity-logger.js';
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
import {
|
||||
ArtifactIntegrityError,
|
||||
PublicationConflictError,
|
||||
ReconciliationError,
|
||||
ReconciliationIoError,
|
||||
readArtifact,
|
||||
} from './artifact-store.js';
|
||||
import type { ArtifactInputDigest, ArtifactRef, PublicationContract, ReconciliationObservation } from './contracts.js';
|
||||
import {
|
||||
isManifest,
|
||||
isManifestCoherent,
|
||||
type ManifestLineageEntry,
|
||||
type PublicationManifest,
|
||||
readPublishedManifest,
|
||||
} from './manifest.js';
|
||||
import { mintTaskReferences } from './materialize-core.js';
|
||||
import { exploitationQueuePath, reconciliationManifestPath, sastProvenancePath } from './prepare.js';
|
||||
import { isProducerId, isTaskReference } from './refs.js';
|
||||
import { RECONCILIATION_SCHEMA_VERSION } from './schema-version.js';
|
||||
import type { FixedTasksBody, ProducerObservationsBody, SupplementalObservationsBody } from './stage-contracts.js';
|
||||
|
||||
// Every key here is internal bookkeeping that must never reach the exploitation queue a downstream
|
||||
// exploit agent reads: a producer ID or source-adapter identifier would tell that agent exactly
|
||||
// which scan producer (and by extension, which internal class/source combination) found the
|
||||
// vulnerability. This exact set is duplicated in prepare.ts (guarding the lost-acknowledgement
|
||||
// repair path there); the two must be kept identical, since a key added to only one would let it
|
||||
// slip through whichever path was not updated.
|
||||
const FORBIDDEN_PUBLISHED_KEYS: ReadonlySet<string> = new Set([
|
||||
'producer_id',
|
||||
'primary_preference',
|
||||
'observation_key',
|
||||
'novelty',
|
||||
'_sastId',
|
||||
'_sast_id',
|
||||
'repository_id',
|
||||
'scan_run_id',
|
||||
]);
|
||||
|
||||
interface PublicationFile {
|
||||
path: string;
|
||||
contents: string;
|
||||
}
|
||||
|
||||
interface PreparedPublication {
|
||||
contract: PublicationContract;
|
||||
files: PublicationFile[];
|
||||
manifest: PublicationManifest;
|
||||
manifestContents: string;
|
||||
manifestSha256: string;
|
||||
producerBody: ProducerObservationsBody;
|
||||
includeSastProvenance: boolean;
|
||||
}
|
||||
|
||||
/** Result returned both for a fresh commit and an existing coherent publication. */
|
||||
export interface PublishClassReconciliationResult {
|
||||
alreadyPublished: boolean;
|
||||
manifestSha256: string;
|
||||
commitHash: string;
|
||||
}
|
||||
|
||||
export interface PublishClassReconciliationOssArgs {
|
||||
deliverablesDir: string;
|
||||
sessionId: string;
|
||||
workspacesDir?: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
producerRef: ArtifactRef<'producer-observations'>;
|
||||
supplementalRef: ArtifactRef<'supplemental-observations'>;
|
||||
fixedTasksRef: ArtifactRef<'fixed-tasks'>;
|
||||
logger: ActivityLogger;
|
||||
}
|
||||
|
||||
function serialize(value: unknown): string {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function sha256Text(contents: string): string {
|
||||
return createHash('sha256').update(contents, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function arraysEqual<T>(first: readonly T[], second: readonly T[]): boolean {
|
||||
return first.length === second.length && first.every((value, index) => value === second[index]);
|
||||
}
|
||||
|
||||
function sameStringSet(first: readonly string[], second: readonly string[]): boolean {
|
||||
return (
|
||||
first.length === second.length &&
|
||||
new Set(first).size === first.length &&
|
||||
first.every((value) => second.includes(value))
|
||||
);
|
||||
}
|
||||
|
||||
/** Derive the exact consumer and manifest paths for one OSS class publication. */
|
||||
export function publicationContractForClass(
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
includeSastProvenance: boolean,
|
||||
): PublicationContract {
|
||||
const requiredOutputPaths = [
|
||||
exploitationQueuePath(vulnerabilityClass),
|
||||
...(includeSastProvenance ? [sastProvenancePath(vulnerabilityClass)] : []),
|
||||
];
|
||||
return {
|
||||
publicationKind: 'class-reconciliation',
|
||||
schemaVersion: RECONCILIATION_SCHEMA_VERSION,
|
||||
manifestPath: reconciliationManifestPath(vulnerabilityClass),
|
||||
requiredOutputPaths,
|
||||
};
|
||||
}
|
||||
|
||||
function producerIdsFromFixed(fixed: FixedTasksBody): string[] {
|
||||
const producerIds: string[] = [];
|
||||
for (const task of fixed.tasks) {
|
||||
producerIds.push(task.producer_id);
|
||||
for (const member of task.merged_from ?? []) producerIds.push(member.producer_id);
|
||||
}
|
||||
return producerIds;
|
||||
}
|
||||
|
||||
// Redact longest IDs first so a short producer ID that is a substring of a longer one cannot
|
||||
// partially rewrite the longer token and leave a recognizable fragment behind.
|
||||
function redactProducerIds(value: string, producerIds: readonly string[]): string {
|
||||
let redacted = value;
|
||||
for (const producerId of [...producerIds].sort((first, second) => second.length - first.length)) {
|
||||
if (redacted.includes(producerId)) redacted = redacted.split(producerId).join('[redacted]');
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function scrubPublishedValue(value: unknown, producerIds: readonly string[]): unknown {
|
||||
if (typeof value === 'string') return redactProducerIds(value, producerIds);
|
||||
if (Array.isArray(value)) return value.map((entry) => scrubPublishedValue(entry, producerIds));
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (FORBIDDEN_PUBLISHED_KEYS.has(key)) continue;
|
||||
const publicKey = redactProducerIds(key, producerIds);
|
||||
if (publicKey in cleaned) {
|
||||
throw new ArtifactIntegrityError('Producer-ID redaction would collide two published evidence keys');
|
||||
}
|
||||
cleaned[publicKey] = scrubPublishedValue(entry, producerIds);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function findForbiddenPublishedKey(value: unknown): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const found = findForbiddenPublishedKey(entry);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (value === null || typeof value !== 'object') return null;
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (FORBIDDEN_PUBLISHED_KEYS.has(key)) return key;
|
||||
const found = findForbiddenPublishedKey(entry);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical consumer queue while retaining evidence and public source annotations.
|
||||
*
|
||||
* Internal producer identity must never reach the published queue that a downstream exploit agent
|
||||
* reads. Three independent passes enforce that: drop forbidden keys and redact ID tokens while
|
||||
* copying, then re-scan the result for any forbidden key, then serialize and fail if any producer
|
||||
* ID string survived. The second and third passes are belt-and-suspenders against a redaction miss.
|
||||
*/
|
||||
export function buildPublishedQueue(fixed: FixedTasksBody): { vulnerabilities: Record<string, unknown>[] } {
|
||||
const producerIds = producerIdsFromFixed(fixed);
|
||||
const scrubbed = scrubPublishedValue({ vulnerabilities: fixed.tasks }, producerIds) as {
|
||||
vulnerabilities: Record<string, unknown>[];
|
||||
};
|
||||
const forbiddenKey = findForbiddenPublishedKey(scrubbed);
|
||||
if (forbiddenKey !== null) {
|
||||
throw new ArtifactIntegrityError(`Published queue retained forbidden internal key ${forbiddenKey}`);
|
||||
}
|
||||
const serialized = JSON.stringify(scrubbed);
|
||||
if (producerIds.some((producerId) => serialized.includes(producerId))) {
|
||||
throw new ArtifactIntegrityError('Published queue retained a producer identifier token');
|
||||
}
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
function observationIds(
|
||||
producerBody: ProducerObservationsBody,
|
||||
supplementalBody: SupplementalObservationsBody,
|
||||
): string[] {
|
||||
return [...producerBody.observations, ...supplementalBody.observations].map((observation) => observation.producer_id);
|
||||
}
|
||||
|
||||
// Confirms a producer ID actually belongs to the namespace its own declared scan_source implies
|
||||
// (VULN for pentest, SAST for static analysis), for the declared class. This is what stops a
|
||||
// mislabeled or forged scan_source from having its identity validated against the wrong producer
|
||||
// namespace, which would otherwise let it dodge the class/source checks the rest of publication
|
||||
// relies on.
|
||||
function validateProducerIdentity(
|
||||
producerId: string,
|
||||
scanSource: ReconciliationObservation['scan_source'],
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): boolean {
|
||||
if (scanSource === 'sast') return isProducerId(producerId, vulnerabilityClass, 'SAST');
|
||||
if (scanSource === 'vulnerability_analysis') return isProducerId(producerId, vulnerabilityClass, 'VULN');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to publish unless the fixed tasks are a complete, exact partition of the observation set.
|
||||
*
|
||||
* Checks, independently: every observation ID appears at most once across the two input bodies;
|
||||
* every task reference is the dense, class-namespaced value materialization should have minted for
|
||||
* its position, with no duplicates; every member's producer ID belongs to the class/source
|
||||
* namespace its own scan_source claims; and the observation-to-task map agrees with task membership
|
||||
* in both directions. Any one of these failing means either an observation was silently dropped or
|
||||
* duplicated, or a task references identity outside its declared boundary, either of which is a
|
||||
* fail-closed reason to abort the publish rather than commit an inconsistent queue.
|
||||
*/
|
||||
function validateFixedTasks(
|
||||
fixed: FixedTasksBody,
|
||||
producerBody: ProducerObservationsBody,
|
||||
supplementalBody: SupplementalObservationsBody,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): void {
|
||||
const expectedObservationIds = observationIds(producerBody, supplementalBody);
|
||||
if (new Set(expectedObservationIds).size !== expectedObservationIds.length) {
|
||||
throw new ArtifactIntegrityError('Publication inputs contain duplicate observation identifiers');
|
||||
}
|
||||
|
||||
const taskIds = new Set<string>();
|
||||
const materializedIds = new Set<string>();
|
||||
const expectedTaskIds = mintTaskReferences(fixed.tasks.length, vulnerabilityClass);
|
||||
for (const [index, task] of fixed.tasks.entries()) {
|
||||
if (!isTaskReference(task.ID, vulnerabilityClass) || task.ID !== expectedTaskIds[index] || taskIds.has(task.ID)) {
|
||||
throw new ArtifactIntegrityError('Fixed tasks contain an invalid or duplicate task reference');
|
||||
}
|
||||
taskIds.add(task.ID);
|
||||
const members = [task, ...(task.merged_from ?? [])];
|
||||
for (const member of members) {
|
||||
if (
|
||||
materializedIds.has(member.producer_id) ||
|
||||
!validateProducerIdentity(member.producer_id, member.scan_source, vulnerabilityClass)
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Fixed tasks contain duplicate or cross-namespace producer identity');
|
||||
}
|
||||
materializedIds.add(member.producer_id);
|
||||
if (fixed.observation_to_task[member.producer_id] !== task.ID) {
|
||||
throw new ArtifactIntegrityError('Observation-to-task map disagrees with fixed task membership');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sameStringSet([...materializedIds], expectedObservationIds)) {
|
||||
throw new ArtifactIntegrityError('Fixed tasks do not cover the complete observation set exactly once');
|
||||
}
|
||||
if (!sameStringSet(Object.keys(fixed.observation_to_task), expectedObservationIds)) {
|
||||
throw new ArtifactIntegrityError('Observation-to-task map is incomplete or contains extra entries');
|
||||
}
|
||||
}
|
||||
|
||||
function lineageHas(
|
||||
inputs: readonly ArtifactInputDigest[],
|
||||
kind: ArtifactInputDigest['artifactKind'],
|
||||
sha256: string,
|
||||
): boolean {
|
||||
return inputs.some((input) => input.artifactKind === kind && input.sha256 === sha256);
|
||||
}
|
||||
|
||||
function assertRefClass(ref: ArtifactRef, vulnerabilityClass: ReconciliationClass): void {
|
||||
if (ref.vulnerabilityClass !== vulnerabilityClass) {
|
||||
throw new ArtifactIntegrityError('Publication artifact reference crosses the declared class boundary');
|
||||
}
|
||||
}
|
||||
|
||||
function buildLineage(fixed: FixedTasksBody): Record<string, ManifestLineageEntry> {
|
||||
const lineage: Record<string, ManifestLineageEntry> = Object.create(null);
|
||||
for (const task of fixed.tasks) {
|
||||
lineage[task.ID] = {
|
||||
primary: task.producer_id,
|
||||
absorbed: (task.merged_from ?? []).map((member) => member.producer_id),
|
||||
};
|
||||
}
|
||||
return lineage;
|
||||
}
|
||||
|
||||
function buildManifest(args: {
|
||||
sessionId: string;
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
producerBody: ProducerObservationsBody;
|
||||
consumerFiles: readonly PublicationFile[];
|
||||
inputDigests: readonly ArtifactInputDigest[];
|
||||
fixed: FixedTasksBody;
|
||||
}): PublicationManifest {
|
||||
return {
|
||||
session_id: args.sessionId,
|
||||
vulnerability_class: args.vulnerabilityClass,
|
||||
schema_version: RECONCILIATION_SCHEMA_VERSION,
|
||||
producer_queue: {
|
||||
path: args.producerBody.producer_queue.path,
|
||||
blob_sha: args.producerBody.producer_queue.blob_sha,
|
||||
},
|
||||
consumer_files: args.consumerFiles.map((file) => ({ path: file.path, sha256: sha256Text(file.contents) })),
|
||||
input_digests: args.inputDigests.map((input) => ({ artifactKind: input.artifactKind, sha256: input.sha256 })),
|
||||
lineage: buildLineage(args.fixed),
|
||||
};
|
||||
}
|
||||
|
||||
async function preparePublication(args: PublishClassReconciliationOssArgs): Promise<PreparedPublication> {
|
||||
assertRefClass(args.producerRef, args.vulnerabilityClass);
|
||||
assertRefClass(args.supplementalRef, args.vulnerabilityClass);
|
||||
assertRefClass(args.fixedTasksRef, args.vulnerabilityClass);
|
||||
|
||||
const producerBody = await readArtifact(args.producerRef, args.sessionId, args.workspacesDir);
|
||||
const supplementalBody = await readArtifact(args.supplementalRef, args.sessionId, args.workspacesDir);
|
||||
const fixed = await readArtifact(args.fixedTasksRef, args.sessionId, args.workspacesDir);
|
||||
if (
|
||||
!Array.isArray(producerBody.observations) ||
|
||||
!isRecord(producerBody.producer_queue) ||
|
||||
!Array.isArray(supplementalBody.observations) ||
|
||||
!Array.isArray(supplementalBody.provenance) ||
|
||||
!Array.isArray(fixed.tasks) ||
|
||||
!isRecord(fixed.observation_to_task)
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Publication artifact body is truncated or malformed');
|
||||
}
|
||||
if (
|
||||
!lineageHas(args.fixedTasksRef.inputs, 'producer-observations', args.producerRef.sha256) ||
|
||||
!lineageHas(args.fixedTasksRef.inputs, 'supplemental-observations', args.supplementalRef.sha256)
|
||||
) {
|
||||
throw new ArtifactIntegrityError('Fixed-task lineage does not name the publication observations');
|
||||
}
|
||||
if (producerBody.producer_queue.path !== exploitationQueuePath(args.vulnerabilityClass)) {
|
||||
throw new ArtifactIntegrityError('Producer artifact names the wrong class queue');
|
||||
}
|
||||
if (supplementalBody.provenance.length !== 0) {
|
||||
throw new ArtifactIntegrityError('Standalone OSS supplemental provenance must remain empty');
|
||||
}
|
||||
validateFixedTasks(fixed, producerBody, supplementalBody, args.vulnerabilityClass);
|
||||
|
||||
const includeSastProvenance = supplementalBody.sarif !== undefined;
|
||||
const contract = publicationContractForClass(args.vulnerabilityClass, includeSastProvenance);
|
||||
const consumerFiles: PublicationFile[] = [
|
||||
{ path: exploitationQueuePath(args.vulnerabilityClass), contents: serialize(buildPublishedQueue(fixed)) },
|
||||
...(includeSastProvenance
|
||||
? [{ path: sastProvenancePath(args.vulnerabilityClass), contents: serialize({ entries: [] }) }]
|
||||
: []),
|
||||
];
|
||||
const inputDigests: ArtifactInputDigest[] = [
|
||||
{ artifactKind: 'producer-observations', sha256: args.producerRef.sha256 },
|
||||
{ artifactKind: 'supplemental-observations', sha256: args.supplementalRef.sha256 },
|
||||
{ artifactKind: 'fixed-tasks', sha256: args.fixedTasksRef.sha256 },
|
||||
];
|
||||
const manifest = buildManifest({
|
||||
sessionId: args.sessionId,
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
producerBody,
|
||||
consumerFiles,
|
||||
inputDigests,
|
||||
fixed,
|
||||
});
|
||||
if (!isManifest(manifest)) {
|
||||
throw new ArtifactIntegrityError('Built OSS publication manifest failed self-validation');
|
||||
}
|
||||
const manifestContents = serialize(manifest);
|
||||
return {
|
||||
contract,
|
||||
files: [...consumerFiles, { path: contract.manifestPath, contents: manifestContents }],
|
||||
manifest,
|
||||
manifestContents,
|
||||
manifestSha256: sha256Text(manifestContents),
|
||||
producerBody,
|
||||
includeSastProvenance,
|
||||
};
|
||||
}
|
||||
|
||||
function lineageEquals(
|
||||
first: Record<string, ManifestLineageEntry>,
|
||||
second: Record<string, ManifestLineageEntry>,
|
||||
): boolean {
|
||||
const firstKeys = Object.keys(first);
|
||||
const secondKeys = Object.keys(second);
|
||||
if (!arraysEqual(firstKeys, secondKeys)) return false;
|
||||
return firstKeys.every((key) => {
|
||||
const firstEntry = first[key];
|
||||
const secondEntry = second[key];
|
||||
return (
|
||||
firstEntry !== undefined &&
|
||||
secondEntry !== undefined &&
|
||||
firstEntry.primary === secondEntry.primary &&
|
||||
arraysEqual(firstEntry.absorbed, secondEntry.absorbed) &&
|
||||
firstEntry.novelty === secondEntry.novelty
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function manifestMatchesPrepared(existing: PublicationManifest, prepared: PublicationManifest): boolean {
|
||||
if (
|
||||
!arraysEqual(
|
||||
existing.input_digests.map((input) => `${input.artifactKind}:${input.sha256}`),
|
||||
prepared.input_digests.map((input) => `${input.artifactKind}:${input.sha256}`),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const expectedConsumers = new Map(prepared.consumer_files.map((consumer) => [consumer.path, consumer.sha256]));
|
||||
if (
|
||||
existing.consumer_files.length !== expectedConsumers.size ||
|
||||
existing.consumer_files.some((consumer) => expectedConsumers.get(consumer.path) !== consumer.sha256)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return lineageEquals(existing.lineage, prepared.lineage);
|
||||
}
|
||||
|
||||
async function verifyCommittedFiles(deliverablesDir: string, files: readonly PublicationFile[]): Promise<void> {
|
||||
for (const file of files) {
|
||||
const committed = await readCommittedFile(deliverablesDir, file.path);
|
||||
if (committed.state !== 'present' || committed.contents !== file.contents) {
|
||||
throw new PublicationConflictError('Committed publication bytes do not match the prepared exact bytes');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt an already-committed publication when the HEAD manifest matches what this call prepared.
|
||||
*
|
||||
* Returns null when nothing is published yet (the caller then commits). Returns a result with
|
||||
* `alreadyPublished: true` when a coherent, byte-identical publication already exists, which is how
|
||||
* a re-drive after a lost acknowledgement converges instead of committing a second time. Any
|
||||
* manifest that exists but disagrees is a conflict, never a silent overwrite. Before returning, it
|
||||
* restores the exact committed paths from HEAD so a partially written retry leaves no dirty bytes.
|
||||
*/
|
||||
async function tryReturnExistingPublication(
|
||||
args: PublishClassReconciliationOssArgs,
|
||||
prepared: PreparedPublication,
|
||||
): Promise<PublishClassReconciliationResult | null> {
|
||||
const manifestRead = await readPublishedManifest(args.deliverablesDir, prepared.contract.manifestPath);
|
||||
if (manifestRead.state === 'absent') return null;
|
||||
if (manifestRead.state === 'invalid') {
|
||||
throw new PublicationConflictError(`Corrupt class manifest in HEAD: ${manifestRead.reason}`);
|
||||
}
|
||||
if (
|
||||
!isManifestCoherent({
|
||||
manifest: manifestRead.manifest,
|
||||
sessionId: args.sessionId,
|
||||
vulnerabilityClass: args.vulnerabilityClass,
|
||||
contract: prepared.contract,
|
||||
producerQueuePath: exploitationQueuePath(args.vulnerabilityClass),
|
||||
producerBlobSha: prepared.producerBody.producer_queue.blob_sha,
|
||||
}) ||
|
||||
!manifestMatchesPrepared(manifestRead.manifest, prepared.manifest)
|
||||
) {
|
||||
throw new PublicationConflictError('Existing class publication conflicts with the prepared publication');
|
||||
}
|
||||
if (!prepared.includeSastProvenance) {
|
||||
const unexpectedProvenance = await readCommittedFile(
|
||||
args.deliverablesDir,
|
||||
sastProvenancePath(args.vulnerabilityClass),
|
||||
);
|
||||
if (unexpectedProvenance.state !== 'absent') {
|
||||
throw new PublicationConflictError('Existing publication has provenance outside its exact manifest path set');
|
||||
}
|
||||
}
|
||||
await preflightSymlinks(args.deliverablesDir, [
|
||||
...prepared.contract.requiredOutputPaths,
|
||||
prepared.contract.manifestPath,
|
||||
]);
|
||||
await verifyCommittedFiles(args.deliverablesDir, prepared.files.slice(0, -1));
|
||||
await restorePathsFromHead(args.deliverablesDir, [
|
||||
...prepared.contract.requiredOutputPaths,
|
||||
prepared.contract.manifestPath,
|
||||
]);
|
||||
const commitHash = await lastCommitForPathAtHead(args.deliverablesDir, prepared.contract.manifestPath);
|
||||
if (commitHash === null) throw new ReconciliationIoError('Unable to read the existing publication commit');
|
||||
return {
|
||||
alreadyPublished: true,
|
||||
manifestSha256: sha256Text(manifestRead.contents),
|
||||
commitHash,
|
||||
};
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
// Refuses to publish through a path that is currently a symlink, so a deliverables directory
|
||||
// entry cannot redirect a publication write to somewhere outside the intended destination. Absence
|
||||
// (`ENOENT`) is not a violation; the path simply does not exist yet, which is the normal case for a
|
||||
// first publish.
|
||||
async function preflightSymlinks(deliverablesDir: string, relativePaths: readonly string[]): Promise<void> {
|
||||
for (const relativePath of relativePaths) {
|
||||
try {
|
||||
const stat = await lstat(path.join(deliverablesDir, relativePath));
|
||||
if (stat.isSymbolicLink()) throw new PublicationConflictError('Refusing to publish through a symlink');
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) continue;
|
||||
if (error instanceof ReconciliationError) throw error;
|
||||
throw new ReconciliationIoError('Unable to inspect a class publication destination');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileReplacingEntry(absolutePath: string, contents: string): Promise<void> {
|
||||
const temporaryPath = `${absolutePath}.tmp-${randomUUID()}`;
|
||||
try {
|
||||
await writeFile(temporaryPath, contents, { flag: 'wx' });
|
||||
await rename(temporaryPath, absolutePath);
|
||||
} catch (error) {
|
||||
await unlink(temporaryPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectedChangedPaths(deliverablesDir: string, files: readonly PublicationFile[]): Promise<string[]> {
|
||||
const changed: string[] = [];
|
||||
for (const file of files) {
|
||||
const committed = await readCommittedFile(deliverablesDir, file.path);
|
||||
if (committed.state === 'corrupt') {
|
||||
throw new PublicationConflictError('Publication destination has corrupt committed state');
|
||||
}
|
||||
if (committed.state === 'absent' || committed.contents !== file.contents) changed.push(file.path);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// The reconciliation prepared against a specific committed producer queue. If that queue changed
|
||||
// in HEAD between preparation and commit, the prepared tasks no longer describe it, so publishing
|
||||
// them would be incoherent. Both the blob SHA and a content digest are checked to catch a change
|
||||
// even if one identity were somehow reused.
|
||||
async function assertProducerStillCurrent(
|
||||
deliverablesDir: string,
|
||||
producerBody: ProducerObservationsBody,
|
||||
): Promise<void> {
|
||||
const queuePath = producerBody.producer_queue.path;
|
||||
const currentBlob = await blobShaFromHead(deliverablesDir, queuePath);
|
||||
if (currentBlob.state !== 'present' || currentBlob.sha !== producerBody.producer_queue.blob_sha) {
|
||||
throw new PublicationConflictError('Producer queue changed after reconciliation preparation');
|
||||
}
|
||||
const current = await readCommittedFile(deliverablesDir, queuePath);
|
||||
if (current.state !== 'present' || sha256Text(current.contents) !== producerBody.producer_queue.digest) {
|
||||
throw new PublicationConflictError('Producer queue bytes changed after reconciliation preparation');
|
||||
}
|
||||
}
|
||||
|
||||
// Standalone provenance with no manifest can only mean a prior publication attempt wrote some of
|
||||
// its files and was interrupted before the manifest (and therefore the whole commit) landed. That is
|
||||
// not a state a fresh publish should build on top of or silently repair by overwriting; it fails
|
||||
// closed and surfaces as a conflict instead.
|
||||
async function ensureNoPartialPreManifestState(
|
||||
deliverablesDir: string,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): Promise<void> {
|
||||
const provenance = await readCommittedFile(deliverablesDir, sastProvenancePath(vulnerabilityClass));
|
||||
if (provenance.state !== 'absent') {
|
||||
throw new PublicationConflictError('Pre-manifest class state contains standalone provenance');
|
||||
}
|
||||
}
|
||||
|
||||
async function commitPreparedPublication(
|
||||
args: PublishClassReconciliationOssArgs,
|
||||
prepared: PreparedPublication,
|
||||
): Promise<PublishClassReconciliationResult> {
|
||||
const ownedPaths = prepared.files.map((file) => file.path);
|
||||
await assertProducerStillCurrent(args.deliverablesDir, prepared.producerBody);
|
||||
await ensureNoPartialPreManifestState(args.deliverablesDir, args.vulnerabilityClass);
|
||||
await preflightSymlinks(args.deliverablesDir, ownedPaths);
|
||||
// Compute the exact set of paths whose bytes differ from HEAD before writing, and hand it to the
|
||||
// commit as the expected delta. The commit rejects any staged change outside this set, so a
|
||||
// dirty sibling file cannot ride along into this publication commit.
|
||||
const expectedChanges = await expectedChangedPaths(args.deliverablesDir, prepared.files);
|
||||
|
||||
try {
|
||||
for (const file of prepared.files) {
|
||||
await writeFileReplacingEntry(path.join(args.deliverablesDir, file.path), file.contents);
|
||||
}
|
||||
} catch (error) {
|
||||
await restorePathsFromHead(args.deliverablesDir, ownedPaths);
|
||||
throw error instanceof ReconciliationError
|
||||
? error
|
||||
: new ReconciliationIoError('Unable to write prepared class publication bytes');
|
||||
}
|
||||
|
||||
let commitHash: string;
|
||||
try {
|
||||
const committed = await commitExactPaths(
|
||||
args.deliverablesDir,
|
||||
ownedPaths,
|
||||
`Publish ${args.vulnerabilityClass} reconciliation`,
|
||||
args.logger,
|
||||
expectedChanges,
|
||||
);
|
||||
commitHash = committed.commitHash;
|
||||
} catch (error) {
|
||||
await restorePathsFromHead(args.deliverablesDir, ownedPaths);
|
||||
if (error instanceof ExactPathCommitMismatchError) {
|
||||
throw new PublicationConflictError('Publication staged path set differs from its exact prepared delta');
|
||||
}
|
||||
throw error instanceof ReconciliationError
|
||||
? error
|
||||
: new ReconciliationIoError('Unable to commit prepared class publication bytes');
|
||||
}
|
||||
|
||||
await verifyCommittedFiles(args.deliverablesDir, prepared.files);
|
||||
return {
|
||||
alreadyPublished: false,
|
||||
manifestSha256: prepared.manifestSha256,
|
||||
commitHash,
|
||||
};
|
||||
}
|
||||
|
||||
/** Publish one OSS class through one reentrant Git critical section. */
|
||||
export async function publishClassReconciliationOss(
|
||||
args: PublishClassReconciliationOssArgs,
|
||||
): Promise<PublishClassReconciliationResult> {
|
||||
const prepared = await preparePublication(args);
|
||||
return withGitRepoLock(async () => {
|
||||
const existing = await tryReturnExistingPublication(args, prepared);
|
||||
if (existing !== null) return existing;
|
||||
return commitPreparedPublication(args, prepared);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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.
|
||||
|
||||
/** Stable producer and exploitation-task reference namespaces. */
|
||||
|
||||
import type { ReconciliationClass } from '../../types/reconciliation.js';
|
||||
|
||||
export const REF_PREFIX: Readonly<Record<ReconciliationClass, string>> = Object.freeze({
|
||||
injection: 'INJ',
|
||||
xss: 'XSS',
|
||||
auth: 'AUTH',
|
||||
authz: 'AUTHZ',
|
||||
ssrf: 'SSRF',
|
||||
miscellaneous: 'MISC',
|
||||
});
|
||||
|
||||
export type ProducerSource = 'VULN' | 'SAST';
|
||||
|
||||
function positiveReferenceNumberPattern(): string {
|
||||
return '0*[1-9][0-9]*';
|
||||
}
|
||||
|
||||
/** The source-aware producer-ID pattern admitted for one internal class. */
|
||||
export function producerIdPattern(vulnClass: ReconciliationClass, source: ProducerSource): RegExp {
|
||||
if (vulnClass === 'miscellaneous' && source === 'VULN') {
|
||||
// The `miscellaneous` class has no vulnerability-analysis producer: it is seeded queue-only
|
||||
// and carries SAST producers alone. This pattern can never match, so any `MISC-VULN-*` ID
|
||||
// is rejected rather than admitted.
|
||||
return /(?!)^/;
|
||||
}
|
||||
return new RegExp(`^${REF_PREFIX[vulnClass]}-${source}-${positiveReferenceNumberPattern()}$`);
|
||||
}
|
||||
|
||||
/** Whether an ID belongs to the class and producer source that declared it. */
|
||||
export function isProducerId(id: string, vulnClass: ReconciliationClass, source: ProducerSource): boolean {
|
||||
return producerIdPattern(vulnClass, source).test(id);
|
||||
}
|
||||
|
||||
/** The stable exploitation-task reference pattern for one internal class. */
|
||||
export function taskReferencePattern(vulnClass: ReconciliationClass): RegExp {
|
||||
return new RegExp(`^${REF_PREFIX[vulnClass]}-${positiveReferenceNumberPattern()}$`);
|
||||
}
|
||||
|
||||
/** Whether an ID is a stable task reference in the declared class namespace. */
|
||||
export function isTaskReference(id: string, vulnClass: ReconciliationClass): boolean {
|
||||
return taskReferencePattern(vulnClass).test(id);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Build the bounded finding context sent to the SAST enrichment model. */
|
||||
|
||||
import type {
|
||||
DataflowFindingContext,
|
||||
FindingContext,
|
||||
LocalizedFindingContext,
|
||||
SarifLocation,
|
||||
SarifResult,
|
||||
} from './types.js';
|
||||
|
||||
function locationInfo(location: SarifLocation): { file: string; line: number; column: number; snippet: string } {
|
||||
const physical = location.physicalLocation;
|
||||
return {
|
||||
file: physical.artifactLocation.uri,
|
||||
line: physical.region.startLine,
|
||||
column: physical.region.startColumn ?? 1,
|
||||
snippet: physical.region.snippet?.text ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function confidenceScore(level: SarifResult['level']): number {
|
||||
if (level === 'error') return 0.9;
|
||||
if (level === 'warning') return 0.6;
|
||||
return 0.3;
|
||||
}
|
||||
|
||||
// Best-effort labeling for the model's context only; a wrong guess here does not affect identity,
|
||||
// dedupe, or which class a finding routes to; it only changes how a step reads in the enrichment prompt.
|
||||
function stepRole(message: string, index: number, lastIndex: number): string {
|
||||
const normalized = message.toLowerCase();
|
||||
if (normalized.includes('sanit')) return 'SANITIZED';
|
||||
if (normalized.startsWith('source')) return 'SOURCE';
|
||||
if (normalized.startsWith('sink')) return 'SINK';
|
||||
if (index === 0) return 'SOURCE';
|
||||
if (index === lastIndex) return 'SINK';
|
||||
return 'HOP';
|
||||
}
|
||||
|
||||
function localizedContext(result: SarifResult): LocalizedFindingContext {
|
||||
const primary = locationInfo(result.locations[0]);
|
||||
return {
|
||||
cwe: result.ruleId,
|
||||
message: result.message.text,
|
||||
severity: result.properties.severity.toLowerCase(),
|
||||
confidence: confidenceScore(result.level),
|
||||
...primary,
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract only validated SARIF fields; no repository identity or fallback path is synthesized. */
|
||||
export function extractContext(result: SarifResult): FindingContext {
|
||||
const locations = result.codeFlows[0]?.threadFlows[0]?.locations;
|
||||
if (locations === undefined || locations.length === 0) return localizedContext(result);
|
||||
|
||||
const lastIndex = locations.length - 1;
|
||||
const dataflowPath = locations.map((step, index) => {
|
||||
const info = locationInfo(step.location);
|
||||
return { ...info, role: stepRole(step.location.message?.text ?? '', index, lastIndex) };
|
||||
});
|
||||
const source = dataflowPath[0];
|
||||
const sink = dataflowPath[lastIndex];
|
||||
if (source === undefined || sink === undefined) return localizedContext(result);
|
||||
|
||||
const context: DataflowFindingContext = {
|
||||
cwe: result.ruleId,
|
||||
message: result.message.text,
|
||||
severity: result.properties.severity.toLowerCase(),
|
||||
confidence: confidenceScore(result.level),
|
||||
sinkFile: sink.file,
|
||||
sinkLine: sink.line,
|
||||
sinkColumn: sink.column,
|
||||
sinkSnippet: sink.snippet,
|
||||
sourceFile: source.file,
|
||||
sourceLine: source.line,
|
||||
sourceColumn: source.column,
|
||||
sourceSnippet: source.snippet,
|
||||
dataflowPath,
|
||||
validationReason: result.properties.description,
|
||||
sanitizationStatus: dataflowPath.some((step) => step.role === 'SANITIZED') ? 'partial' : 'none',
|
||||
};
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Deterministic bare-CWE routing into Shannon's six internal classes. */
|
||||
|
||||
import type { ReconciliationClass } from '../../../types/reconciliation.js';
|
||||
import type { Confidence, CWEMapping, ShannonCategory } from './types.js';
|
||||
|
||||
export const CWE_TO_CATEGORY: Readonly<Record<string, CWEMapping>> = Object.freeze({
|
||||
'CWE-89': { category: 'INJECTION', name: 'SQL Injection', priority: 'P1' },
|
||||
'CWE-78': { category: 'INJECTION', name: 'OS Command Injection', priority: 'P1' },
|
||||
'CWE-95': { category: 'INJECTION', name: 'Code/Eval Injection', priority: 'P1' },
|
||||
'CWE-94': { category: 'INJECTION', name: 'Code Injection', priority: 'P1' },
|
||||
'CWE-502': { category: 'INJECTION', name: 'Deserialization', priority: 'P1' },
|
||||
'CWE-611': { category: 'INJECTION', name: 'XXE', priority: 'P1' },
|
||||
'CWE-22': { category: 'INJECTION', name: 'Path Traversal', priority: 'P1' },
|
||||
'CWE-434': { category: 'INJECTION', name: 'Unrestricted File Upload', priority: 'P1' },
|
||||
'CWE-943': { category: 'INJECTION', name: 'NoSQL Injection', priority: 'P1' },
|
||||
'CWE-93': { category: 'INJECTION', name: 'CRLF Injection', priority: 'P2' },
|
||||
'CWE-117': { category: 'INJECTION', name: 'Log Injection', priority: 'P2' },
|
||||
'CWE-470': { category: 'INJECTION', name: 'Unsafe Reflection', priority: 'P2' },
|
||||
'CWE-829': { category: 'INJECTION', name: 'Untrusted Function Inclusion', priority: 'P1' },
|
||||
'CWE-643': { category: 'INJECTION', name: 'XPath Injection', priority: 'P2' },
|
||||
'CWE-90': { category: 'INJECTION', name: 'LDAP Injection', priority: 'P2' },
|
||||
'CWE-91': { category: 'INJECTION', name: 'XML Injection', priority: 'P2' },
|
||||
'CWE-1336': { category: 'INJECTION', name: 'Template Injection', priority: 'P1' },
|
||||
'CWE-1427': { category: 'INJECTION', name: 'Prompt Injection', priority: 'P2' },
|
||||
'CWE-1321': { category: 'INJECTION', name: 'Prototype Pollution', priority: 'P1' },
|
||||
'CWE-548': { category: 'INJECTION', name: 'Directory Listing', priority: 'P3' },
|
||||
|
||||
'CWE-79': { category: 'XSS', name: 'Cross-Site Scripting', priority: 'P1' },
|
||||
|
||||
'CWE-287': { category: 'AUTH', name: 'Broken Authentication', priority: 'P1' },
|
||||
'CWE-798': { category: 'AUTH', name: 'Hard-coded Credentials', priority: 'P1' },
|
||||
'CWE-319': { category: 'AUTH', name: 'Cleartext Transmission', priority: 'P2' },
|
||||
'CWE-330': { category: 'AUTH', name: 'Insufficient Randomness', priority: 'P2' },
|
||||
'CWE-346': { category: 'AUTH', name: 'Origin Validation Error', priority: 'P2' },
|
||||
'CWE-295': { category: 'AUTH', name: 'Improper Certificate Validation', priority: 'P2' },
|
||||
'CWE-347': { category: 'AUTH', name: 'Improper Signature Verification', priority: 'P2' },
|
||||
'CWE-326': { category: 'AUTH', name: 'Inadequate Encryption', priority: 'P3' },
|
||||
'CWE-329': { category: 'AUTH', name: 'Predictable IV', priority: 'P3' },
|
||||
'CWE-323': { category: 'AUTH', name: 'Nonce or Key Pair Reuse', priority: 'P2' },
|
||||
'CWE-327': { category: 'AUTH', name: 'Broken Cryptographic Algorithm', priority: 'P3' },
|
||||
'CWE-328': { category: 'AUTH', name: 'Weak Hash', priority: 'P3' },
|
||||
'CWE-916': { category: 'AUTH', name: 'Weak Password Hash Effort', priority: 'P3' },
|
||||
'CWE-614': { category: 'AUTH', name: 'Cookie without Secure Flag', priority: 'P3' },
|
||||
'CWE-942': { category: 'AUTH', name: 'Permissive Cross-domain Policy', priority: 'P3' },
|
||||
'CWE-1004': { category: 'AUTH', name: 'Cookie without HttpOnly', priority: 'P3' },
|
||||
'CWE-522': { category: 'AUTH', name: 'Insufficiently Protected Credentials', priority: 'P1' },
|
||||
'CWE-306': { category: 'AUTH', name: 'Missing Authentication', priority: 'P1' },
|
||||
'CWE-208': { category: 'AUTH', name: 'Timing Side-Channel', priority: 'P2' },
|
||||
'CWE-338': { category: 'AUTH', name: 'Weak PRNG', priority: 'P2' },
|
||||
|
||||
'CWE-639': { category: 'AUTHZ', name: 'IDOR', priority: 'P1' },
|
||||
'CWE-285': { category: 'AUTHZ', name: 'Broken Authorization', priority: 'P1' },
|
||||
'CWE-269': { category: 'AUTHZ', name: 'Privilege Escalation', priority: 'P1' },
|
||||
'CWE-284': { category: 'AUTHZ', name: 'Improper Access Control', priority: 'P1' },
|
||||
'CWE-653': { category: 'AUTHZ', name: 'Data Isolation Failure', priority: 'P1' },
|
||||
'CWE-732': { category: 'AUTHZ', name: 'Incorrect Permission Assignment', priority: 'P2' },
|
||||
'CWE-862': { category: 'AUTHZ', name: 'Missing Authorization', priority: 'P1' },
|
||||
'CWE-378': { category: 'AUTHZ', name: 'Permission Issue', priority: 'P2' },
|
||||
'CWE-359': { category: 'AUTHZ', name: 'PII Exposure', priority: 'P1' },
|
||||
'CWE-915': { category: 'AUTHZ', name: 'Mass Assignment', priority: 'P1' },
|
||||
|
||||
'CWE-918': { category: 'SSRF', name: 'Server-Side Request Forgery', priority: 'P1' },
|
||||
|
||||
'CWE-601': { category: 'MISC', name: 'Open Redirect', priority: 'P2' },
|
||||
'CWE-693': { category: 'MISC', name: 'Protection Mechanism Failure', priority: 'P3' },
|
||||
'CWE-1021': { category: 'MISC', name: 'Clickjacking', priority: 'P3' },
|
||||
'CWE-1333': { category: 'MISC', name: 'ReDoS', priority: 'P3' },
|
||||
'CWE-489': { category: 'MISC', name: 'Active Debug Code', priority: 'P3' },
|
||||
'CWE-352': { category: 'MISC', name: 'CSRF', priority: 'P1' },
|
||||
'CWE-532': { category: 'MISC', name: 'Sensitive Logging', priority: 'P3' },
|
||||
'CWE-311': { category: 'MISC', name: 'Missing Encryption at Rest', priority: 'P3' },
|
||||
'CWE-922': { category: 'MISC', name: 'Insecure Storage', priority: 'P3' },
|
||||
'CWE-1236': { category: 'MISC', name: 'CSV Formula Injection', priority: 'P2' },
|
||||
});
|
||||
|
||||
// Routing to `miscellaneous` (rather than dropping the finding) is what lets every schema-valid
|
||||
// SARIF result reach exploitation even when its CWE is not one of the ones Shannon names explicitly:
|
||||
// an unrecognized bare CWE still gets its own producer identity and its own exploitation task, just
|
||||
// without a specific category name and at the lowest priority.
|
||||
/** Unknown, but schema-valid, bare CWEs are retained for the generalist class. */
|
||||
export function unmappedMapping(ruleId: string): CWEMapping {
|
||||
return { category: 'MISC', name: ruleId, priority: 'P3' };
|
||||
}
|
||||
|
||||
export function vulnerabilityClassToCategory(vulnerabilityClass: ReconciliationClass): ShannonCategory {
|
||||
const categories: Record<ReconciliationClass, ShannonCategory> = {
|
||||
injection: 'INJECTION',
|
||||
xss: 'XSS',
|
||||
auth: 'AUTH',
|
||||
authz: 'AUTHZ',
|
||||
ssrf: 'SSRF',
|
||||
miscellaneous: 'MISC',
|
||||
};
|
||||
return categories[vulnerabilityClass];
|
||||
}
|
||||
|
||||
export function normalizeConfidence(confidence: string | undefined): Confidence | undefined {
|
||||
if (confidence === undefined) return undefined;
|
||||
const normalized = confidence.toLowerCase();
|
||||
if (normalized === 'med' || normalized === 'medium') return 'medium';
|
||||
if (normalized === 'high' || normalized === 'low') return normalized;
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/** One bounded structured-generation request for one nonempty class batch. */
|
||||
|
||||
import type { ReconciliationClass } from '../../../../types/reconciliation.js';
|
||||
import type {
|
||||
StructuredGenerationPort,
|
||||
StructuredGenerationRequest,
|
||||
StructuredGenerationResult,
|
||||
} from '../../../structured-generation.js';
|
||||
import { SAST_ENRICHMENT_TOOL_DESCRIPTION, sastEnrichmentToolSchema } from './schema.js';
|
||||
import { extractVulnerabilities } from './validate.js';
|
||||
|
||||
export interface SastEnrichmentUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
export type SastEnrichmentBatchOutcome =
|
||||
| { status: 'ok'; vulnerabilities: unknown[]; usage: SastEnrichmentUsage }
|
||||
| { status: 'aborted'; usage: SastEnrichmentUsage; message: string }
|
||||
| { status: 'failed'; usage: SastEnrichmentUsage; message: string; terminal: boolean };
|
||||
|
||||
export interface SastEnrichmentBatchRequest {
|
||||
vulnerabilityClass: ReconciliationClass;
|
||||
prompt: string;
|
||||
findingsJson: string;
|
||||
maxTokens: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// `terminal` marks a failure the stage should not retry. It is set only when the provider itself
|
||||
// reported a non-retryable failure; an incomplete or empty response defaults to non-terminal so
|
||||
// Temporal drives another attempt.
|
||||
function isTerminalProviderFailure(result: StructuredGenerationResult): boolean {
|
||||
return result.providerFailure?.retryable === false;
|
||||
}
|
||||
|
||||
export async function runSastEnrichmentBatch<TModelContext>(
|
||||
port: StructuredGenerationPort<TModelContext>,
|
||||
modelContext: TModelContext,
|
||||
request: SastEnrichmentBatchRequest,
|
||||
): Promise<SastEnrichmentBatchOutcome> {
|
||||
const generationRequest: StructuredGenerationRequest = {
|
||||
userContent: `${request.prompt}\n${request.findingsJson}`,
|
||||
tool: {
|
||||
name: 'submit_result',
|
||||
description: SAST_ENRICHMENT_TOOL_DESCRIPTION,
|
||||
parametersJsonSchema: sastEnrichmentToolSchema(request.vulnerabilityClass),
|
||||
},
|
||||
maxTokens: request.maxTokens,
|
||||
...(request.signal !== undefined && { signal: request.signal }),
|
||||
};
|
||||
const result = await port.generate(generationRequest, modelContext);
|
||||
const usage = result.usage;
|
||||
|
||||
if (result.stopReason === 'aborted') {
|
||||
if (request.signal?.aborted === true) {
|
||||
return { status: 'aborted', usage, message: 'SAST enrichment was cancelled' };
|
||||
}
|
||||
return {
|
||||
status: 'failed',
|
||||
usage,
|
||||
message: 'SAST enrichment request ended before producing a result',
|
||||
terminal: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
result.stopReason !== 'toolUse' ||
|
||||
result.toolCalls.length !== 1 ||
|
||||
result.toolCalls[0]?.name !== 'submit_result'
|
||||
) {
|
||||
return {
|
||||
status: 'failed',
|
||||
usage,
|
||||
message: 'SAST enrichment did not return one complete submit_result call',
|
||||
terminal: isTerminalProviderFailure(result),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return { status: 'ok', vulnerabilities: extractVulnerabilities(result.toolCalls[0].arguments), usage };
|
||||
} catch {
|
||||
return {
|
||||
status: 'failed',
|
||||
usage,
|
||||
message: 'SAST enrichment returned an invalid response envelope',
|
||||
terminal: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Code-owned SAST observation shaping and producer-ID minting. */
|
||||
|
||||
import type { ReconciliationClass } from '../../../../types/reconciliation.js';
|
||||
import { ArtifactIntegrityError } from '../../artifact-store.js';
|
||||
import type { ReconciliationObservation, SastSourceLocation } from '../../contracts.js';
|
||||
import { isProducerId, REF_PREFIX } from '../../refs.js';
|
||||
import type { ClassifiedFinding, FindingContext } from '../types.js';
|
||||
|
||||
export function sourceLocationFromContext(context: FindingContext): SastSourceLocation {
|
||||
if ('sinkFile' in context) {
|
||||
return {
|
||||
file: context.sinkFile,
|
||||
line: context.sinkLine,
|
||||
column: context.sinkColumn,
|
||||
rule_id: context.cwe,
|
||||
};
|
||||
}
|
||||
return { file: context.file, line: context.line, column: context.column, rule_id: context.cwe };
|
||||
}
|
||||
|
||||
// SAST producer IDs occupy a namespace (`PREFIX-SAST-NN`) disjoint from vulnerability-analysis
|
||||
// producer IDs (`PREFIX-VULN-NN`) even within the same class, so the two sources can never collide
|
||||
// on identity and `validateProducerIdentity` in publish.ts can tell them apart by construction.
|
||||
export function mintSastProducerId(vulnerabilityClass: ReconciliationClass, sastId: number): string {
|
||||
const producerId = `${REF_PREFIX[vulnerabilityClass]}-SAST-${String(sastId + 1).padStart(2, '0')}`;
|
||||
if (!isProducerId(producerId, vulnerabilityClass, 'SAST')) {
|
||||
throw new ArtifactIntegrityError('Minted SAST producer identifier is outside its class namespace');
|
||||
}
|
||||
return producerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one SAST-origin observation, always marked `preferred`.
|
||||
*
|
||||
* This is where the dedupe contract's primacy rule is established for a static-analysis finding: if
|
||||
* reconciliation later groups this observation with a pentest observation for the same underlying
|
||||
* vulnerability, this one becomes the task's primary record, since it carries an exact source
|
||||
* location and rule ID that a pentest finding does not.
|
||||
*/
|
||||
export function buildSastObservation(
|
||||
producerId: string,
|
||||
evidence: Record<string, unknown>,
|
||||
finding: ClassifiedFinding,
|
||||
): ReconciliationObservation {
|
||||
return {
|
||||
...evidence,
|
||||
producer_id: producerId,
|
||||
scan_source: 'sast',
|
||||
primary_preference: 'preferred',
|
||||
priority: finding.mapping.priority,
|
||||
sast_source_location: sourceLocationFromContext(finding.context),
|
||||
} as ReconciliationObservation;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Closed per-class submit schema for one SAST enrichment response. */
|
||||
|
||||
import type { ReconciliationClass } from '../../../../types/reconciliation.js';
|
||||
import { QUEUE_ENTRY_FIELD_NAMES } from '../../../queue-schemas.js';
|
||||
|
||||
// `ID` is omitted from the filtered class field list because it is prepended manually below instead
|
||||
// (so it always appears exactly once); `code_locations` is omitted entirely, because that field is a
|
||||
// structured array the model is never asked to reconstruct. Source location is authoritative code-owned
|
||||
// data derived straight from validated SARIF (see sourceLocationFromContext in policy.ts), not
|
||||
// something a free-text model response is trusted to supply.
|
||||
const OMITTED_MODEL_FIELDS = new Set(['ID', 'code_locations']);
|
||||
|
||||
/** Evidence fields the model may return for one class, excluding authoritative source metadata. */
|
||||
export function sastEnrichmentFieldNames(vulnerabilityClass: ReconciliationClass): readonly string[] {
|
||||
return [
|
||||
'ID',
|
||||
...QUEUE_ENTRY_FIELD_NAMES[vulnerabilityClass].filter((field) => !OMITTED_MODEL_FIELDS.has(field)),
|
||||
'_sastId',
|
||||
];
|
||||
}
|
||||
|
||||
function propertySchema(name: string): Record<string, unknown> {
|
||||
if (name === '_sastId') {
|
||||
return { type: 'integer', description: 'Copy the input _sastId exactly.' };
|
||||
}
|
||||
if (name === 'externally_exploitable') return { type: 'boolean' };
|
||||
if (name === 'confidence') return { type: 'string', description: 'high | med | low' };
|
||||
return { type: 'string' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope and each returned object are closed. Required-field and pairing
|
||||
* checks remain application-owned so a malformed sibling can be dropped alone.
|
||||
*/
|
||||
export function sastEnrichmentToolSchema(vulnerabilityClass: ReconciliationClass): Record<string, unknown> {
|
||||
const properties = Object.fromEntries(
|
||||
sastEnrichmentFieldNames(vulnerabilityClass).map((field) => [field, propertySchema(field)]),
|
||||
);
|
||||
return {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
vulnerabilities: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['vulnerabilities'],
|
||||
};
|
||||
}
|
||||
|
||||
export const SAST_ENRICHMENT_TOOL_DESCRIPTION =
|
||||
'Return the enriched exploitation-queue vulnerabilities. Call exactly once as your final action.';
|
||||
|
||||
export function enrichmentPromptName(vulnerabilityClass: ReconciliationClass): string {
|
||||
return `sast-enrichment-${vulnerabilityClass}`;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/** Validate, pair, and positively project one enrichment response. */
|
||||
|
||||
import type { ReconciliationClass } from '../../../../types/reconciliation.js';
|
||||
import { normalizeConfidence } from '../cwe-mapper.js';
|
||||
import type { ClassifiedFinding, Confidence } from '../types.js';
|
||||
import { sastEnrichmentFieldNames } from './schema.js';
|
||||
|
||||
export class EnrichmentAttemptError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'EnrichmentAttemptError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface PairedVulnerability {
|
||||
finding: ClassifiedFinding;
|
||||
sastId: number;
|
||||
evidence: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ValidationCounts {
|
||||
returned: number;
|
||||
malformed: number;
|
||||
orphaned: number;
|
||||
duplicate_sast_id: number;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
paired: PairedVulnerability[];
|
||||
counts: ValidationCounts;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function normalizeEnrichedConfidence(value: string): Confidence | undefined {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalizeConfidence(normalized === 'moderate' ? 'medium' : normalized);
|
||||
}
|
||||
|
||||
/** Require the exact closed `{ vulnerabilities: [...] }` response envelope. */
|
||||
export function extractVulnerabilities(toolArguments: unknown): unknown[] {
|
||||
if (!isRecord(toolArguments) || !Array.isArray(toolArguments.vulnerabilities)) {
|
||||
throw new EnrichmentAttemptError('submit_result did not return a vulnerabilities array');
|
||||
}
|
||||
if (Object.keys(toolArguments).length !== 1 || !Object.hasOwn(toolArguments, 'vulnerabilities')) {
|
||||
throw new EnrichmentAttemptError('submit_result returned unexpected envelope fields');
|
||||
}
|
||||
return toolArguments.vulnerabilities;
|
||||
}
|
||||
|
||||
function toRequiredString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === 'boolean') return String(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true') return true;
|
||||
if (normalized === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toSastId(value: unknown): number | undefined {
|
||||
let parsed: number;
|
||||
if (typeof value === 'number') {
|
||||
parsed = value;
|
||||
} else if (typeof value === 'string' && value.trim().length > 0) {
|
||||
parsed = Number(value.trim());
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
interface CoercedCommonFields {
|
||||
element: Record<string, unknown>;
|
||||
confidenceRaw: string;
|
||||
sastId: number | undefined;
|
||||
}
|
||||
|
||||
function coerceCommonFields(element: Record<string, unknown>): CoercedCommonFields | undefined {
|
||||
const id = toRequiredString(element.ID);
|
||||
const vulnerabilityType = toRequiredString(element.vulnerability_type);
|
||||
const externallyExploitable = toBoolean(element.externally_exploitable);
|
||||
const notes = toRequiredString(element.notes);
|
||||
const confidencePresent = element.confidence !== undefined && element.confidence !== null;
|
||||
if (
|
||||
id === undefined ||
|
||||
vulnerabilityType === undefined ||
|
||||
externallyExploitable === undefined ||
|
||||
notes === undefined ||
|
||||
!confidencePresent
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Confidence is present but not a scalar (an object or array). Carry a sentinel that no confidence
|
||||
// vocabulary can normalize, so the caller rejects the whole response rather than guessing a value.
|
||||
const scalarConfidence = toRequiredString(element.confidence);
|
||||
const confidenceRaw = scalarConfidence ?? '[non-scalar]';
|
||||
const sastId = toSastId(element._sastId);
|
||||
return {
|
||||
element: {
|
||||
...element,
|
||||
ID: id,
|
||||
vulnerability_type: vulnerabilityType,
|
||||
externally_exploitable: externallyExploitable,
|
||||
confidence: confidenceRaw,
|
||||
notes,
|
||||
...(sastId !== undefined && { _sastId: sastId }),
|
||||
},
|
||||
confidenceRaw,
|
||||
sastId,
|
||||
};
|
||||
}
|
||||
|
||||
function coerceEvidenceValue(key: string, value: unknown): unknown {
|
||||
if (key === 'externally_exploitable') return value;
|
||||
return toRequiredString(value) ?? JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildEvidence(
|
||||
element: Record<string, unknown>,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
confidence: Confidence,
|
||||
): Record<string, unknown> {
|
||||
const evidence: Record<string, unknown> = {};
|
||||
for (const key of sastEnrichmentFieldNames(vulnerabilityClass)) {
|
||||
if (key === 'ID' || key === '_sastId' || key === 'confidence') continue;
|
||||
const value = element[key];
|
||||
if (value !== null && value !== undefined) evidence[key] = coerceEvidenceValue(key, value);
|
||||
}
|
||||
evidence.confidence = confidence;
|
||||
return evidence;
|
||||
}
|
||||
|
||||
// The model's own `ID` field (a free-text string it invents) is carried through as evidence but is
|
||||
// never trusted for identity: only `_sastId`, the small integer the code itself assigned before the
|
||||
// request, is looked up in `findingsById`. A model cannot forge or guess its way into pairing with a
|
||||
// finding it was not actually sent, since `_sastId` values outside the sent batch simply have no entry.
|
||||
/** Pair by code-assigned `_sastId`; never by response order or model-supplied ID. */
|
||||
export function pairEnrichedVulnerabilities(
|
||||
returned: readonly unknown[],
|
||||
findingsById: ReadonlyMap<number, ClassifiedFinding>,
|
||||
vulnerabilityClass: ReconciliationClass,
|
||||
): ValidationResult {
|
||||
const paired: PairedVulnerability[] = [];
|
||||
const consumed = new Set<number>();
|
||||
let malformed = 0;
|
||||
let orphaned = 0;
|
||||
let duplicate = 0;
|
||||
const allowedFields = new Set(sastEnrichmentFieldNames(vulnerabilityClass));
|
||||
|
||||
for (const value of returned) {
|
||||
if (!isRecord(value)) {
|
||||
malformed++;
|
||||
continue;
|
||||
}
|
||||
if (Object.keys(value).some((key) => !allowedFields.has(key))) {
|
||||
malformed++;
|
||||
continue;
|
||||
}
|
||||
const common = coerceCommonFields(value);
|
||||
if (common === undefined) {
|
||||
malformed++;
|
||||
continue;
|
||||
}
|
||||
const confidence = normalizeEnrichedConfidence(common.confidenceRaw);
|
||||
if (confidence === undefined) {
|
||||
throw new EnrichmentAttemptError('Response has an unrecognized confidence value');
|
||||
}
|
||||
const sastId = common.sastId;
|
||||
const finding = sastId === undefined ? undefined : findingsById.get(sastId);
|
||||
if (sastId === undefined || finding === undefined) {
|
||||
orphaned++;
|
||||
continue;
|
||||
}
|
||||
if (consumed.has(sastId)) {
|
||||
duplicate++;
|
||||
continue;
|
||||
}
|
||||
consumed.add(sastId);
|
||||
paired.push({
|
||||
finding,
|
||||
sastId,
|
||||
evidence: buildEvidence(common.element, vulnerabilityClass, confidence),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
paired,
|
||||
counts: {
|
||||
returned: returned.length,
|
||||
malformed,
|
||||
orphaned,
|
||||
duplicate_sast_id: duplicate,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Digest-pinned, workspace-contained SARIF byte intake. */
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Stats } from 'node:fs';
|
||||
import { lstat, readFile, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WORKSPACES_DIR } from '../../../paths.js';
|
||||
import type { SarifRef } from '../../sast/types.js';
|
||||
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type SarifIntakeFailureKind = 'invalid' | 'io';
|
||||
|
||||
export class SarifIntakeError extends Error {
|
||||
readonly kind: SarifIntakeFailureKind;
|
||||
|
||||
constructor(message: string, kind: SarifIntakeFailureKind = 'invalid') {
|
||||
super(message);
|
||||
this.name = 'SarifIntakeError';
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, ...codes: readonly string[]): boolean {
|
||||
return error instanceof Error && codes.includes((error as NodeJS.ErrnoException).code ?? '');
|
||||
}
|
||||
|
||||
function intakeFileSystemError(error: unknown, invalidMessage: string, ioMessage: string): SarifIntakeError {
|
||||
if (isErrno(error, 'ENOENT', 'ENOTDIR', 'ELOOP')) {
|
||||
return new SarifIntakeError(invalidMessage);
|
||||
}
|
||||
return new SarifIntakeError(ioMessage, 'io');
|
||||
}
|
||||
|
||||
function validSessionId(sessionId: string): boolean {
|
||||
return (
|
||||
sessionId.length > 0 &&
|
||||
sessionId !== '.' &&
|
||||
sessionId !== '..' &&
|
||||
!sessionId.includes('/') &&
|
||||
!sessionId.includes('\\') &&
|
||||
!sessionId.includes('\0')
|
||||
);
|
||||
}
|
||||
|
||||
function contained(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
/** Read exact SARIF bytes only after containment and no-symlink checks. */
|
||||
export async function readPinnedSarif(
|
||||
sessionId: string,
|
||||
ref: SarifRef,
|
||||
workspacesDir: string = WORKSPACES_DIR,
|
||||
): Promise<Buffer> {
|
||||
if (!validSessionId(sessionId)) throw new SarifIntakeError('Invalid SARIF session identifier');
|
||||
if (!SHA256_PATTERN.test(ref.sha256)) throw new SarifIntakeError('SARIF reference digest is invalid');
|
||||
|
||||
let realWorkspaces: string;
|
||||
try {
|
||||
realWorkspaces = await realpath(workspacesDir);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(
|
||||
error,
|
||||
'SARIF workspace root is not visible',
|
||||
'SARIF workspace root could not be resolved',
|
||||
);
|
||||
}
|
||||
const sessionRoot = path.join(realWorkspaces, sessionId);
|
||||
let sessionStat: Stats;
|
||||
try {
|
||||
sessionStat = await lstat(sessionRoot);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(
|
||||
error,
|
||||
'SARIF session workspace is not visible',
|
||||
'SARIF session workspace could not be inspected',
|
||||
);
|
||||
}
|
||||
if (sessionStat.isSymbolicLink() || !sessionStat.isDirectory()) {
|
||||
throw new SarifIntakeError('SARIF session workspace is not a regular directory');
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(ref.path) || path.normalize(ref.path) !== ref.path || !contained(sessionRoot, ref.path)) {
|
||||
throw new SarifIntakeError('SARIF path is outside the current scan workspace');
|
||||
}
|
||||
|
||||
// Walk every path segment under the session root and reject a symlink at any level. Checking only
|
||||
// the final component would let a symlinked parent directory redirect the read outside the
|
||||
// workspace before the byte read and digest check ever run.
|
||||
const relativeSegments = path.relative(sessionRoot, ref.path).split(path.sep);
|
||||
let current = sessionRoot;
|
||||
for (const segment of relativeSegments) {
|
||||
current = path.join(current, segment);
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await lstat(current);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(error, 'SARIF path is not visible', 'SARIF path could not be inspected');
|
||||
}
|
||||
if (stat.isSymbolicLink()) throw new SarifIntakeError('SARIF path contains a symlink');
|
||||
}
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(ref.path);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(error, 'SARIF path is not visible', 'SARIF path could not be resolved');
|
||||
}
|
||||
if (resolvedPath !== ref.path || !contained(sessionRoot, resolvedPath)) {
|
||||
throw new SarifIntakeError('SARIF path escapes the current scan workspace');
|
||||
}
|
||||
|
||||
let finalStat: Stats;
|
||||
try {
|
||||
finalStat = await lstat(resolvedPath);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(error, 'SARIF path is not visible', 'SARIF path could not be inspected');
|
||||
}
|
||||
if (!finalStat.isFile()) throw new SarifIntakeError('SARIF path is not a regular file');
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readFile(resolvedPath);
|
||||
} catch (error) {
|
||||
throw intakeFileSystemError(error, 'SARIF bytes are not readable', 'SARIF bytes could not be read');
|
||||
}
|
||||
const observed = createHash('sha256').update(bytes).digest('hex');
|
||||
if (observed !== ref.sha256) throw new SarifIntakeError('SARIF digest does not match the exact bytes');
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/** Appendix A SARIF validator. Document failures throw; invalid findings are classified and dropped. */
|
||||
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
DroppedSarifFinding,
|
||||
ParsedSarif,
|
||||
ParsedSarifFinding,
|
||||
SarifFindingDropReason,
|
||||
SarifLocation,
|
||||
SarifResult,
|
||||
SastPhase,
|
||||
} from './types.js';
|
||||
|
||||
const CWE_PATTERN = /^CWE-[1-9][0-9]*$/;
|
||||
const SEVERITIES = new Set(['Critical', 'High', 'Medium', 'Low', 'Info']);
|
||||
const LEVELS = new Set(['error', 'warning', 'note']);
|
||||
|
||||
/** A run/document contract failure that invalidates the supplied SARIF reference. */
|
||||
export class SarifDocumentError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SarifDocumentError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function nonemptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
// Phase is inferred from the tool name string rather than an explicit field, since the SARIF
|
||||
// contract does not carry a phase number directly. This is a best-effort classification: an
|
||||
// unrecognized name falls through to phase 3, the generic case, rather than failing the finding.
|
||||
function detectPhase(toolName: string): SastPhase {
|
||||
const lower = toolName.toLowerCase();
|
||||
if (lower.includes('check') || lower.includes('phase0') || lower.includes('phase_0')) return 0;
|
||||
if (lower.includes('logic') || lower.includes('business') || lower.includes('phase4') || lower.includes('phase_4')) {
|
||||
return 4;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
function hasTraversal(uri: string): boolean {
|
||||
return uri.split('/').some((segment) => segment === '..' || segment === '.');
|
||||
}
|
||||
|
||||
/** Whether a SARIF location is a normalized repository-relative POSIX path. */
|
||||
export function isRepositoryRelativeSarifUri(uri: string): boolean {
|
||||
if (uri.length === 0 || uri.trim() !== uri || uri.includes('\\') || uri.includes('\0')) return false;
|
||||
if (path.posix.isAbsolute(uri) || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(uri)) return false;
|
||||
if (uri.includes('//') || hasTraversal(uri)) return false;
|
||||
return path.posix.normalize(uri) === uri;
|
||||
}
|
||||
|
||||
function validateRule(value: unknown): string {
|
||||
if (!isRecord(value) || !nonemptyString(value.id) || !CWE_PATTERN.test(value.id)) {
|
||||
throw new SarifDocumentError('SARIF rule metadata has an invalid CWE id');
|
||||
}
|
||||
if (!nonemptyString(value.name) || typeof value.helpUri !== 'string') {
|
||||
throw new SarifDocumentError('SARIF rule metadata is missing required strings');
|
||||
}
|
||||
for (const descriptionKey of ['shortDescription', 'fullDescription']) {
|
||||
const description = value[descriptionKey];
|
||||
if (!isRecord(description) || typeof description.text !== 'string') {
|
||||
throw new SarifDocumentError('SARIF rule metadata has an invalid description');
|
||||
}
|
||||
}
|
||||
if (!isRecord(value.properties) || value.properties.cwe !== value.id || !Array.isArray(value.properties.tags)) {
|
||||
throw new SarifDocumentError('SARIF rule metadata does not match its CWE id');
|
||||
}
|
||||
if (!value.properties.tags.every((tag) => typeof tag === 'string')) {
|
||||
throw new SarifDocumentError('SARIF rule tags are invalid');
|
||||
}
|
||||
return value.id;
|
||||
}
|
||||
|
||||
// `requireSourceRoot` differs between callers: a result's primary location must declare
|
||||
// `uriBaseId: '%SRCROOT%'` explicitly (it is the location a finding is keyed on), while a code-flow
|
||||
// step's location may omit `uriBaseId` entirely and is only rejected if it names something other
|
||||
// than `%SRCROOT%`.
|
||||
function locationParts(
|
||||
value: unknown,
|
||||
requireSourceRoot: boolean,
|
||||
): {
|
||||
location?: SarifLocation;
|
||||
reason?: SarifFindingDropReason;
|
||||
} {
|
||||
if (!isRecord(value) || !isRecord(value.physicalLocation)) return { reason: 'location' };
|
||||
const physical = value.physicalLocation;
|
||||
if (!isRecord(physical.artifactLocation) || !isRecord(physical.region)) return { reason: 'location' };
|
||||
const artifact = physical.artifactLocation;
|
||||
const region = physical.region;
|
||||
if (typeof artifact.uri !== 'string') return { reason: 'location' };
|
||||
if (requireSourceRoot && artifact.uriBaseId !== '%SRCROOT%') return { reason: 'location' };
|
||||
if (!requireSourceRoot && artifact.uriBaseId !== undefined && artifact.uriBaseId !== '%SRCROOT%') {
|
||||
return { reason: 'location' };
|
||||
}
|
||||
if (hasTraversal(artifact.uri)) return { reason: 'traversal' };
|
||||
if (!isRepositoryRelativeSarifUri(artifact.uri)) return { reason: 'location' };
|
||||
if (!Number.isSafeInteger(region.startLine) || (region.startLine as number) <= 0) return { reason: 'line' };
|
||||
if (
|
||||
region.startColumn !== undefined &&
|
||||
(!Number.isSafeInteger(region.startColumn) || (region.startColumn as number) <= 0)
|
||||
) {
|
||||
return { reason: 'location' };
|
||||
}
|
||||
if (region.snippet !== undefined && (!isRecord(region.snippet) || typeof region.snippet.text !== 'string')) {
|
||||
return { reason: 'location' };
|
||||
}
|
||||
if (value.message !== undefined && (!isRecord(value.message) || typeof value.message.text !== 'string')) {
|
||||
return { reason: 'location' };
|
||||
}
|
||||
return { location: value as unknown as SarifLocation };
|
||||
}
|
||||
|
||||
function validateCodeFlows(value: unknown, primary: SarifLocation): SarifFindingDropReason | undefined {
|
||||
if (!Array.isArray(value) || value.length === 0) return 'malformed';
|
||||
let firstFlowLastLocation: SarifLocation | undefined;
|
||||
|
||||
for (let flowIndex = 0; flowIndex < value.length; flowIndex++) {
|
||||
const flow = value[flowIndex];
|
||||
if (!isRecord(flow) || !Array.isArray(flow.threadFlows) || flow.threadFlows.length === 0) return 'malformed';
|
||||
for (let threadIndex = 0; threadIndex < flow.threadFlows.length; threadIndex++) {
|
||||
const thread = flow.threadFlows[threadIndex];
|
||||
if (!isRecord(thread) || !Array.isArray(thread.locations) || thread.locations.length === 0) return 'malformed';
|
||||
for (let locationIndex = 0; locationIndex < thread.locations.length; locationIndex++) {
|
||||
const step = thread.locations[locationIndex];
|
||||
if (!isRecord(step) || !nonemptyString(step.importance)) return 'malformed';
|
||||
const checked = locationParts(step.location, false);
|
||||
if (checked.reason !== undefined) return checked.reason;
|
||||
if (
|
||||
!isRecord(step.location) ||
|
||||
!isRecord(step.location.message) ||
|
||||
typeof step.location.message.text !== 'string'
|
||||
) {
|
||||
return 'malformed';
|
||||
}
|
||||
if (flowIndex === 0 && threadIndex === 0 && locationIndex === thread.locations.length - 1) {
|
||||
firstFlowLastLocation = checked.location;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The last step of the first thread flow is the sink, and it must land on the same file and line
|
||||
// as the result's primary location. A code flow whose sink disagrees with the reported location
|
||||
// describes a different defect than it claims, so the finding is dropped.
|
||||
const primaryPhysical = primary.physicalLocation;
|
||||
const sinkPhysical = firstFlowLastLocation?.physicalLocation;
|
||||
if (
|
||||
sinkPhysical === undefined ||
|
||||
sinkPhysical.artifactLocation.uri !== primaryPhysical.artifactLocation.uri ||
|
||||
sinkPhysical.region.startLine !== primaryPhysical.region.startLine
|
||||
) {
|
||||
return 'location';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resultRuleId(value: unknown): string | undefined {
|
||||
return isRecord(value) && typeof value.ruleId === 'string' ? value.ruleId : undefined;
|
||||
}
|
||||
|
||||
function validateResult(value: unknown, rules: ReadonlySet<string>): SarifFindingDropReason | SarifResult {
|
||||
if (!isRecord(value)) return 'malformed';
|
||||
if (!nonemptyString(value.ruleId) || !CWE_PATTERN.test(value.ruleId)) return 'rule';
|
||||
if (!isRecord(value.properties) || value.properties.cwe !== value.ruleId || !rules.has(value.ruleId)) return 'rule';
|
||||
if (!isRecord(value.message) || typeof value.message.text !== 'string') return 'malformed';
|
||||
if (!LEVELS.has(value.level as string)) return 'severity';
|
||||
if (!Array.isArray(value.locations) || value.locations.length === 0) return 'location';
|
||||
const primary = locationParts(value.locations[0], true);
|
||||
if (primary.reason !== undefined) return primary.reason;
|
||||
if (primary.location === undefined) return 'location';
|
||||
|
||||
const codeFlowReason = validateCodeFlows(value.codeFlows, primary.location);
|
||||
if (codeFlowReason !== undefined) return codeFlowReason;
|
||||
|
||||
if (!SEVERITIES.has(value.properties.severity as string)) return 'severity';
|
||||
if (value.properties.status !== 'verified') return 'status';
|
||||
if (value.properties.findingSubType !== 'AGENT_SAST') return 'subtype';
|
||||
if (typeof value.properties.description !== 'string') return 'malformed';
|
||||
// These property names belong to a richer internal finding shape than the one this pipeline
|
||||
// accepts; a result carrying any of them was not produced against this exact SARIF contract (or
|
||||
// carries content, such as a proof-of-concept, this pipeline never wants to ingest), so it is
|
||||
// dropped rather than accepted with those fields silently ignored.
|
||||
for (const forbidden of ['invariantDescription', 'owasp_category', 'proofOfConcept']) {
|
||||
if (Object.hasOwn(value.properties, forbidden)) return 'malformed';
|
||||
}
|
||||
return value as unknown as SarifResult;
|
||||
}
|
||||
|
||||
function zeroDropReasons(): Record<SarifFindingDropReason, number> {
|
||||
return {
|
||||
malformed: 0,
|
||||
rule: 0,
|
||||
location: 0,
|
||||
traversal: 0,
|
||||
line: 0,
|
||||
severity: 0,
|
||||
status: 0,
|
||||
subtype: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse and strictly validate the Capella SARIF byte contract. */
|
||||
export function parseSarifContent(content: string): ParsedSarif {
|
||||
let document: unknown;
|
||||
try {
|
||||
document = JSON.parse(content);
|
||||
} catch {
|
||||
throw new SarifDocumentError('SARIF document is not valid JSON');
|
||||
}
|
||||
if (!isRecord(document)) throw new SarifDocumentError('SARIF document is not an object');
|
||||
if (!nonemptyString(document.$schema) || document.version !== '2.1.0') {
|
||||
throw new SarifDocumentError('SARIF document metadata is invalid');
|
||||
}
|
||||
if (!Array.isArray(document.runs) || document.runs.length === 0) {
|
||||
throw new SarifDocumentError('SARIF document has no runs');
|
||||
}
|
||||
|
||||
const findings: ParsedSarifFinding[] = [];
|
||||
const droppedFindings: DroppedSarifFinding[] = [];
|
||||
const droppedByReason = zeroDropReasons();
|
||||
let declaredFindings = 0;
|
||||
|
||||
for (const runValue of document.runs) {
|
||||
if (!isRecord(runValue) || !isRecord(runValue.tool) || !isRecord(runValue.tool.driver)) {
|
||||
throw new SarifDocumentError('SARIF run tool metadata is invalid');
|
||||
}
|
||||
const driver = runValue.tool.driver;
|
||||
if (!nonemptyString(driver.name) || !nonemptyString(driver.version) || !nonemptyString(driver.informationUri)) {
|
||||
throw new SarifDocumentError('SARIF run driver metadata is incomplete');
|
||||
}
|
||||
if (!Array.isArray(driver.rules) || !Array.isArray(runValue.results) || !isRecord(runValue.properties)) {
|
||||
throw new SarifDocumentError('SARIF run arrays or properties are invalid');
|
||||
}
|
||||
if (
|
||||
typeof runValue.properties.repository !== 'string' ||
|
||||
!Number.isSafeInteger(runValue.properties.totalFindings) ||
|
||||
runValue.properties.totalFindings !== runValue.results.length
|
||||
) {
|
||||
throw new SarifDocumentError('SARIF run finding count or repository metadata is invalid');
|
||||
}
|
||||
|
||||
const rules = new Set<string>();
|
||||
for (const rule of driver.rules) {
|
||||
const id = validateRule(rule);
|
||||
if (rules.has(id)) throw new SarifDocumentError('SARIF run contains duplicate rule metadata');
|
||||
rules.add(id);
|
||||
}
|
||||
|
||||
const phase = detectPhase(driver.name);
|
||||
declaredFindings += runValue.results.length;
|
||||
for (const resultValue of runValue.results) {
|
||||
const validation = validateResult(resultValue, rules);
|
||||
if (typeof validation === 'string') {
|
||||
droppedByReason[validation]++;
|
||||
const ruleId = resultRuleId(resultValue);
|
||||
droppedFindings.push({ ...(ruleId !== undefined && { ruleId }), reason: validation });
|
||||
continue;
|
||||
}
|
||||
findings.push({ result: validation, phase, toolName: driver.name });
|
||||
}
|
||||
}
|
||||
|
||||
return { findings, droppedFindings, droppedByReason, declaredFindings };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Strict SARIF and SAST-enrichment contracts. */
|
||||
|
||||
export type ShannonCategory = 'INJECTION' | 'XSS' | 'AUTH' | 'AUTHZ' | 'SSRF' | 'MISC';
|
||||
export type Priority = 'P1' | 'P2' | 'P3';
|
||||
export type Confidence = 'high' | 'medium' | 'low';
|
||||
// Which analysis phase produced a finding, detected from the SARIF driver's tool name (see
|
||||
// `detectPhase` in sarif-parser.ts): 0 is an early check-style phase, 4 is business-logic analysis,
|
||||
// and 3 is every other phase. Kept as a small closed set of numbers rather than named phase strings
|
||||
// because it only needs to round-trip through enrichment, not describe the phase to a person.
|
||||
export type SastPhase = 0 | 3 | 4;
|
||||
|
||||
export interface CWEMapping {
|
||||
category: ShannonCategory;
|
||||
name: string;
|
||||
priority: Priority;
|
||||
}
|
||||
|
||||
export interface SarifRegion {
|
||||
startLine: number;
|
||||
startColumn?: number;
|
||||
snippet?: { text: string };
|
||||
}
|
||||
|
||||
export interface SarifArtifactLocation {
|
||||
uri: string;
|
||||
uriBaseId?: '%SRCROOT%';
|
||||
}
|
||||
|
||||
export interface SarifLocation {
|
||||
physicalLocation: {
|
||||
artifactLocation: SarifArtifactLocation;
|
||||
region: SarifRegion;
|
||||
};
|
||||
message?: { text: string };
|
||||
}
|
||||
|
||||
export interface SarifThreadFlowLocation {
|
||||
location: SarifLocation;
|
||||
importance: string;
|
||||
}
|
||||
|
||||
export interface SarifResult {
|
||||
ruleId: string;
|
||||
level: 'error' | 'warning' | 'note';
|
||||
message: { text: string };
|
||||
locations: [SarifLocation, ...unknown[]];
|
||||
codeFlows: Array<{
|
||||
threadFlows: Array<{
|
||||
locations: SarifThreadFlowLocation[];
|
||||
}>;
|
||||
}>;
|
||||
properties: {
|
||||
severity: 'Critical' | 'High' | 'Medium' | 'Low' | 'Info';
|
||||
cwe: string;
|
||||
status: 'verified';
|
||||
description: string;
|
||||
findingSubType: 'AGENT_SAST';
|
||||
};
|
||||
}
|
||||
|
||||
export type SarifFindingDropReason =
|
||||
| 'malformed'
|
||||
| 'rule'
|
||||
| 'location'
|
||||
| 'traversal'
|
||||
| 'line'
|
||||
| 'severity'
|
||||
| 'status'
|
||||
| 'subtype';
|
||||
|
||||
export interface ParsedSarifFinding {
|
||||
result: SarifResult;
|
||||
phase: SastPhase;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
export interface DroppedSarifFinding {
|
||||
ruleId?: string;
|
||||
reason: SarifFindingDropReason;
|
||||
}
|
||||
|
||||
export interface ParsedSarif {
|
||||
findings: ParsedSarifFinding[];
|
||||
droppedFindings: DroppedSarifFinding[];
|
||||
droppedByReason: Record<SarifFindingDropReason, number>;
|
||||
declaredFindings: number;
|
||||
}
|
||||
|
||||
export interface DataflowFindingContext {
|
||||
cwe: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
confidence: number;
|
||||
sinkFile: string;
|
||||
sinkLine: number;
|
||||
sinkColumn: number;
|
||||
sinkSnippet: string;
|
||||
sourceFile: string;
|
||||
sourceLine: number;
|
||||
sourceColumn: number;
|
||||
sourceSnippet: string;
|
||||
dataflowPath: Array<{
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
snippet: string;
|
||||
role: string;
|
||||
}>;
|
||||
validationReason: string;
|
||||
sanitizationStatus: string;
|
||||
}
|
||||
|
||||
export interface LocalizedFindingContext {
|
||||
cwe: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
confidence: number;
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
// A dataflow context is built when a finding's code flow names a distinct source and sink;
|
||||
// otherwise the finding gets a localized context describing only its single reported location.
|
||||
export type FindingContext = DataflowFindingContext | LocalizedFindingContext;
|
||||
|
||||
export interface ClassifiedFinding {
|
||||
context: FindingContext;
|
||||
mapping: CWEMapping;
|
||||
phase: SastPhase;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Schema version shared by every reconciliation artifact and publication.
|
||||
*
|
||||
* Keep this leaf module free of runtime imports so workflow-safe type modules can
|
||||
* refer to the version without pulling filesystem code into a workflow bundle.
|
||||
*/
|
||||
export const RECONCILIATION_SCHEMA_VERSION = 1 as const;
|
||||
@@ -0,0 +1,205 @@
|
||||
// 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.
|
||||
|
||||
/** Durable, queue-only producer seeding for the analysis-less `miscellaneous` class. */
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
commitExactPaths,
|
||||
ExactPathCommitMismatchError,
|
||||
gitBlobShaForContents,
|
||||
lastCommitForPathAtHead,
|
||||
readCommittedFile,
|
||||
restorePathsFromHead,
|
||||
withGitRepoLock,
|
||||
} from '../../services/git-manager.js';
|
||||
import type { ActivityLogger } from '../../types/activity-logger.js';
|
||||
import { PublicationConflictError, ReconciliationError, ReconciliationIoError } from './artifact-store.js';
|
||||
import { isManifestCoherent, readPublishedManifest } from './manifest.js';
|
||||
import { exploitationQueuePath, reconciliationManifestPath, sastProvenancePath } from './prepare.js';
|
||||
import { publicationContractForClass } from './publish.js';
|
||||
|
||||
/**
|
||||
* Canonical committed producer bytes for the analysis-less class.
|
||||
*
|
||||
* Every seed writes these exact bytes, so a re-run after a lost acknowledgement produces an
|
||||
* identical blob and the seed is idempotent. Any other committed content for the `miscellaneous` queue is
|
||||
* treated as a conflict, never overwritten.
|
||||
*/
|
||||
export const CANONICAL_EMPTY_MISCELLANEOUS_QUEUE = `${JSON.stringify({ vulnerabilities: [] }, null, 2)}\n`;
|
||||
|
||||
export interface SeedEmptyProducerQueueArgs {
|
||||
deliverablesDir: string;
|
||||
sessionId: string;
|
||||
logger: ActivityLogger;
|
||||
}
|
||||
|
||||
export interface SeedEmptyProducerQueueResult {
|
||||
alreadySeeded: boolean;
|
||||
alreadyPublished: boolean;
|
||||
commitHash: string;
|
||||
}
|
||||
|
||||
function sha256Text(contents: string): string {
|
||||
return createHash('sha256').update(contents, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
async function rejectQueueSymlink(deliverablesDir: string, queuePath: string): Promise<void> {
|
||||
try {
|
||||
const stat = await lstat(path.join(deliverablesDir, queuePath));
|
||||
if (stat.isSymbolicLink()) throw new PublicationConflictError('Refusing to seed through a symlink');
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) return;
|
||||
if (error instanceof ReconciliationError) throw error;
|
||||
throw new ReconciliationIoError('Unable to inspect the miscellaneous producer-queue destination');
|
||||
}
|
||||
}
|
||||
|
||||
async function writeQueueReplacingEntry(absolutePath: string): Promise<void> {
|
||||
const temporaryPath = `${absolutePath}.tmp-${randomUUID()}`;
|
||||
try {
|
||||
await writeFile(temporaryPath, CANONICAL_EMPTY_MISCELLANEOUS_QUEUE, { flag: 'wx' });
|
||||
await rename(temporaryPath, absolutePath);
|
||||
} catch (error) {
|
||||
await unlink(temporaryPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function publicationCommit(deliverablesDir: string, relativePath: string): Promise<string> {
|
||||
const commitHash = await lastCommitForPathAtHead(deliverablesDir, relativePath);
|
||||
if (commitHash === null) throw new ReconciliationIoError('Unable to read the exact-path publication commit');
|
||||
return commitHash;
|
||||
}
|
||||
|
||||
async function verifyManifestConsumers(
|
||||
deliverablesDir: string,
|
||||
consumers: ReadonlyArray<{ path: string; sha256: string }>,
|
||||
): Promise<void> {
|
||||
for (const consumer of consumers) {
|
||||
const committed = await readCommittedFile(deliverablesDir, consumer.path);
|
||||
if (committed.state !== 'present' || sha256Text(committed.contents) !== consumer.sha256) {
|
||||
throw new PublicationConflictError('Existing miscellaneous publication is missing a coherent committed consumer');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed `miscellaneous_exploitation_queue.json` once, or return an existing seed/final publication.
|
||||
*
|
||||
* Resolves to one of three states under the Git lock: a coherent final publication already exists
|
||||
* and is adopted (`alreadyPublished`); the canonical empty queue is already committed and adopted
|
||||
* (`alreadySeeded`); or nothing is committed yet and the canonical queue is written and committed
|
||||
* fresh. A committed queue with non-canonical bytes, or standalone provenance with no manifest,
|
||||
* is a conflict rather than a state to reprocess.
|
||||
*/
|
||||
// The `miscellaneous` class has no analysis agent of its own: nothing runs vulnerability analysis
|
||||
// against it directly, so unlike the five core classes it never gets a producer queue from an
|
||||
// upstream agent. Seeding the canonical empty queue here, then running it through the same
|
||||
// publish/manifest machinery as every other class, means downstream consumers (materialization,
|
||||
// the report, the exploitation phase) never need a miscellaneous-specific code path for "this class
|
||||
// might not have a queue file at all."
|
||||
export async function seedEmptyProducerQueue(args: SeedEmptyProducerQueueArgs): Promise<SeedEmptyProducerQueueResult> {
|
||||
const queuePath = exploitationQueuePath('miscellaneous');
|
||||
const manifestPath = reconciliationManifestPath('miscellaneous');
|
||||
const provenancePath = sastProvenancePath('miscellaneous');
|
||||
const finalContracts = [
|
||||
publicationContractForClass('miscellaneous', false),
|
||||
publicationContractForClass('miscellaneous', true),
|
||||
];
|
||||
|
||||
return withGitRepoLock(async (): Promise<SeedEmptyProducerQueueResult> => {
|
||||
const manifestRead = await readPublishedManifest(args.deliverablesDir, manifestPath);
|
||||
if (manifestRead.state === 'invalid') {
|
||||
throw new PublicationConflictError(`Corrupt miscellaneous manifest in HEAD: ${manifestRead.reason}`);
|
||||
}
|
||||
if (manifestRead.state === 'present') {
|
||||
const producerBlobSha = await gitBlobShaForContents(args.deliverablesDir, CANONICAL_EMPTY_MISCELLANEOUS_QUEUE);
|
||||
const coherentFinalContract = finalContracts.some((contract) =>
|
||||
isManifestCoherent({
|
||||
manifest: manifestRead.manifest,
|
||||
sessionId: args.sessionId,
|
||||
vulnerabilityClass: 'miscellaneous',
|
||||
contract,
|
||||
producerQueuePath: queuePath,
|
||||
producerBlobSha,
|
||||
}),
|
||||
);
|
||||
if (!coherentFinalContract) {
|
||||
throw new PublicationConflictError(
|
||||
'Existing miscellaneous manifest does not cohere with the final publication',
|
||||
);
|
||||
}
|
||||
await verifyManifestConsumers(args.deliverablesDir, manifestRead.manifest.consumer_files);
|
||||
await rejectQueueSymlink(args.deliverablesDir, queuePath);
|
||||
await restorePathsFromHead(args.deliverablesDir, [queuePath]);
|
||||
return {
|
||||
alreadySeeded: true,
|
||||
alreadyPublished: true,
|
||||
commitHash: await publicationCommit(args.deliverablesDir, manifestPath),
|
||||
};
|
||||
}
|
||||
|
||||
const provenanceRead = await readCommittedFile(args.deliverablesDir, provenancePath);
|
||||
if (provenanceRead.state !== 'absent') {
|
||||
throw new PublicationConflictError('Pre-manifest miscellaneous state contains standalone provenance');
|
||||
}
|
||||
|
||||
const queueRead = await readCommittedFile(args.deliverablesDir, queuePath);
|
||||
if (queueRead.state === 'corrupt') {
|
||||
throw new PublicationConflictError('Committed miscellaneous producer queue is unreadable');
|
||||
}
|
||||
if (queueRead.state === 'present') {
|
||||
if (queueRead.contents !== CANONICAL_EMPTY_MISCELLANEOUS_QUEUE) {
|
||||
throw new PublicationConflictError('Pre-manifest miscellaneous producer queue is not canonical empty state');
|
||||
}
|
||||
await rejectQueueSymlink(args.deliverablesDir, queuePath);
|
||||
await restorePathsFromHead(args.deliverablesDir, [queuePath]);
|
||||
return {
|
||||
alreadySeeded: true,
|
||||
alreadyPublished: false,
|
||||
commitHash: await publicationCommit(args.deliverablesDir, queuePath),
|
||||
};
|
||||
}
|
||||
|
||||
await rejectQueueSymlink(args.deliverablesDir, queuePath);
|
||||
try {
|
||||
await writeQueueReplacingEntry(path.join(args.deliverablesDir, queuePath));
|
||||
} catch {
|
||||
await restorePathsFromHead(args.deliverablesDir, [queuePath]);
|
||||
throw new ReconciliationIoError('Unable to write the canonical miscellaneous producer queue');
|
||||
}
|
||||
|
||||
let commitHash: string;
|
||||
try {
|
||||
const committed = await commitExactPaths(
|
||||
args.deliverablesDir,
|
||||
[queuePath],
|
||||
'Seed miscellaneous producer queue',
|
||||
args.logger,
|
||||
[queuePath],
|
||||
);
|
||||
commitHash = committed.commitHash;
|
||||
} catch (error) {
|
||||
await restorePathsFromHead(args.deliverablesDir, [queuePath]);
|
||||
if (error instanceof ExactPathCommitMismatchError) {
|
||||
throw new PublicationConflictError('Miscellaneous seed staged path set differs from its queue-only contract');
|
||||
}
|
||||
throw new ReconciliationIoError('Unable to commit the canonical miscellaneous producer queue');
|
||||
}
|
||||
const committedQueue = await readCommittedFile(args.deliverablesDir, queuePath);
|
||||
if (committedQueue.state !== 'present' || committedQueue.contents !== CANONICAL_EMPTY_MISCELLANEOUS_QUEUE) {
|
||||
throw new PublicationConflictError('Committed miscellaneous seed bytes do not match canonical empty state');
|
||||
}
|
||||
return { alreadySeeded: false, alreadyPublished: false, commitHash };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/** Type-only wire and artifact-body contracts for reconciliation stages. */
|
||||
|
||||
import type { SarifRef } from '../sast/types.js';
|
||||
import type {
|
||||
ArtifactRef,
|
||||
ClassEvidence,
|
||||
Priority,
|
||||
ReconciliationObservation,
|
||||
ReconciliationTask,
|
||||
SastSourceLocation,
|
||||
ScanSource,
|
||||
} from './contracts.js';
|
||||
|
||||
// "Positive" projection means this type is built by copying named fields in, never by taking the
|
||||
// full observation and deleting fields out. A field that is not explicitly listed here (including
|
||||
// `producer_id` and every other internal key) cannot appear on this type at all, so a future field
|
||||
// added to `ReconciliationObservation` is model-invisible by default instead of leaking by default.
|
||||
/** Positive model projection of one observation. */
|
||||
export type ObservationView<E extends ClassEvidence = ClassEvidence> = E & {
|
||||
scan_source: ScanSource;
|
||||
priority?: Priority;
|
||||
sast_source_location?: SastSourceLocation;
|
||||
};
|
||||
|
||||
export interface TaskFormationInput {
|
||||
queued_findings: Array<{ label: string; entry: ObservationView }>;
|
||||
}
|
||||
|
||||
export interface TaskFormationOutput {
|
||||
groups: Array<{ queue_labels: string[]; reasoning: string }>;
|
||||
}
|
||||
|
||||
// Identifies the exact committed producer queue a reconciliation run was prepared against. Publish
|
||||
// re-reads this queue at commit time and compares both the Git blob SHA and the content digest, so
|
||||
// a queue that changed in HEAD between preparation and commit is caught rather than silently
|
||||
// published against stale tasks.
|
||||
export interface ProducerQueueIdentity {
|
||||
path: string;
|
||||
blob_sha: string;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export interface ProducerObservationsBody {
|
||||
observations: ReconciliationObservation[];
|
||||
producer_queue: ProducerQueueIdentity;
|
||||
}
|
||||
|
||||
/** Optional adapter-facing provenance row. Standalone enrichment emits no rows. */
|
||||
export interface SupplementalProvenanceRecord {
|
||||
producer_id: string;
|
||||
repository_id?: string;
|
||||
scan_run_id?: string;
|
||||
rule_id: string;
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
// Counts only, never identities: this is telemetry surfaced to the scan log, not a channel that
|
||||
// carries any producer ID or SARIF content forward. See `dropped_findings` on the body below for
|
||||
// the one place a dropped finding's identity is actually retained.
|
||||
export interface SupplementalDropCounts {
|
||||
unknown_cwe: number;
|
||||
other_category: number;
|
||||
malformed: number;
|
||||
orphaned: number;
|
||||
duplicate_sast_id: number;
|
||||
enrichment_dropped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of one finding that was sent for enrichment but never paired back.
|
||||
*
|
||||
* Recovered from the sent side, so it is always complete: a malformed response may
|
||||
* carry no usable `sastId`, which is precisely what makes it malformed.
|
||||
*/
|
||||
export interface SupplementalDroppedFinding {
|
||||
producer_id: string;
|
||||
sast_id: number;
|
||||
sast_source_location: SastSourceLocation;
|
||||
}
|
||||
|
||||
export interface SupplementalObservationsBody {
|
||||
observations: ReconciliationObservation[];
|
||||
provenance: SupplementalProvenanceRecord[];
|
||||
sarif?: SarifRef;
|
||||
drops: SupplementalDropCounts;
|
||||
// Sibling of `drops`, never a member of it: `drops` is spread into the artifact envelope's
|
||||
// numeric `counts` map, which rejects any non-integer value.
|
||||
dropped_findings: SupplementalDroppedFinding[];
|
||||
}
|
||||
|
||||
export interface AcceptedTaskGroup {
|
||||
producer_ids: string[];
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
// `model_ran` is false for both the "fewer than two observations" skip and any future zero-request
|
||||
// path; it lets a reader of this artifact tell a genuine empty result apart from a model call that
|
||||
// simply produced no groups.
|
||||
export interface TaskFormationBody {
|
||||
model_ran: boolean;
|
||||
groups: AcceptedTaskGroup[];
|
||||
rejected_group_count: number;
|
||||
dropped_unknown_label_count: number;
|
||||
}
|
||||
|
||||
// `observation_to_task` is the complete forward index from every observation's producer ID to the
|
||||
// task it was materialized into (whether as primary or as a merged member). Publication uses it to
|
||||
// prove every observation was placed exactly once before anything is written.
|
||||
export interface FixedTasksBody {
|
||||
tasks: ReconciliationTask[];
|
||||
observation_to_task: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ArtifactBodyMap {
|
||||
'producer-observations': ProducerObservationsBody;
|
||||
'supplemental-observations': SupplementalObservationsBody;
|
||||
'task-formation': TaskFormationBody;
|
||||
'fixed-tasks': FixedTasksBody;
|
||||
}
|
||||
|
||||
export interface PrepareAlreadyPublished {
|
||||
outcome: 'already_published';
|
||||
manifestSha256: string;
|
||||
}
|
||||
|
||||
export interface PreparePending {
|
||||
outcome: 'pending';
|
||||
ref: ArtifactRef<'producer-observations'>;
|
||||
}
|
||||
|
||||
export type PrepareResult = PrepareAlreadyPublished | PreparePending;
|
||||
|
||||
export interface StageMetrics {
|
||||
costUsd: number;
|
||||
modelCalls: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface EnrichSuccess {
|
||||
ref: ArtifactRef<'supplemental-observations'>;
|
||||
metrics: StageMetrics;
|
||||
}
|
||||
|
||||
export interface FormSuccess {
|
||||
ref: ArtifactRef<'task-formation'>;
|
||||
metrics: StageMetrics;
|
||||
}
|
||||
|
||||
// Sentinel result of task formation when the model stage exhausted its retries on an eligible
|
||||
// failure. Materialization treats it as an instruction to skip grouping and give every observation
|
||||
// its own task, so a reconciliation still completes without any formation artifact.
|
||||
export const SINGLETON_FALLBACK = 'singleton_fallback' as const;
|
||||
export type FormResult = FormSuccess | typeof SINGLETON_FALLBACK;
|
||||
|
||||
export interface MaterializeResult {
|
||||
ref: ArtifactRef<'fixed-tasks'>;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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.
|
||||
|
||||
/** Captured submit tool with closed-schema and cross-group validation before capture. */
|
||||
|
||||
import { defineTool } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import { Value } from 'typebox/value';
|
||||
import type { CapturedSubmitTool } from '../submit-tool.js';
|
||||
|
||||
export type SubmitValidator = (parameters: unknown) => readonly string[];
|
||||
|
||||
export interface ValidatingSubmitTool extends CapturedSubmitTool {
|
||||
readonly getAcceptedCount: () => number;
|
||||
readonly sawRejectedSubmission: () => boolean;
|
||||
}
|
||||
|
||||
function rejection(problems: readonly string[]): {
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
details: undefined;
|
||||
} {
|
||||
const detail = problems.map((problem) => `- ${problem}`).join('\n');
|
||||
const message = `Your answer was not accepted. Fix the following and call submit_result again:\n${detail}`;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ status: 'error', errorType: 'ValidationError', retryable: true, message }),
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const VALIDATING_DIRECTIVE =
|
||||
'\n\nDeliver your structured answer by calling submit_result. If it reports problems, correct them and call it ' +
|
||||
'again. Once it accepts your answer, stop. Do not output JSON as text.';
|
||||
|
||||
/** Build one executor-owned submit tool that captures only an accepted, schema-closed payload. */
|
||||
export function createValidatingSubmitTool(
|
||||
schema: Record<string, unknown>,
|
||||
validate: SubmitValidator,
|
||||
): ValidatingSubmitTool {
|
||||
const parametersSchema = Type.Unsafe(schema);
|
||||
let captured: unknown;
|
||||
let acceptedCount = 0;
|
||||
let rejected = false;
|
||||
|
||||
return {
|
||||
tool: defineTool({
|
||||
name: 'submit_result',
|
||||
label: 'Submit task groups',
|
||||
description: 'Submit task groups. Correct a rejected submission, then stop after the first accepted call.',
|
||||
promptSnippet: 'submit_result: submit task groups; correct and resubmit only when rejected',
|
||||
promptGuidelines: [
|
||||
'Call submit_result to deliver task groups.',
|
||||
'If it reports validation problems, correct them and call it again.',
|
||||
'Stop after the first accepted submission. Do not output JSON as text.',
|
||||
],
|
||||
parameters: parametersSchema,
|
||||
async execute(_toolCallId, parameters) {
|
||||
if (!Value.Check(parametersSchema, parameters)) {
|
||||
rejected = true;
|
||||
return rejection(['The submission does not match the closed task-formation schema.']);
|
||||
}
|
||||
|
||||
const problems = validate(parameters);
|
||||
if (problems.length > 0) {
|
||||
rejected = true;
|
||||
return rejection(problems);
|
||||
}
|
||||
|
||||
// Only the first accepted submission is captured. A second accepted call is refused and
|
||||
// terminates the session, so the stage always materializes from a single settled answer
|
||||
// rather than silently taking the last of several.
|
||||
acceptedCount += 1;
|
||||
if (acceptedCount > 1) {
|
||||
rejected = true;
|
||||
return {
|
||||
...rejection(['Only one accepted submission is allowed.']),
|
||||
terminate: true,
|
||||
};
|
||||
}
|
||||
captured = parameters;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Task groups accepted.' }],
|
||||
details: undefined,
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getCaptured: () => captured,
|
||||
getAcceptedCount: () => acceptedCount,
|
||||
sawRejectedSubmission: () => rejected,
|
||||
directive: VALIDATING_DIRECTIVE,
|
||||
};
|
||||
}
|
||||
|
||||
export const PROBLEM_LABEL_PREVIEW = 6;
|
||||
|
||||
export function previewLabels(labels: readonly string[], maximum: number = PROBLEM_LABEL_PREVIEW): string {
|
||||
const shown = labels.slice(0, maximum).join(', ');
|
||||
const remaining = labels.length - maximum;
|
||||
return remaining > 0 ? `${shown} (+${remaining} more)` : shown;
|
||||
}
|
||||
|
||||
export function describeUnknownLabels(unknown: readonly string[], kind: string): string {
|
||||
if (unknown.length === 0) return '';
|
||||
return `These are not labels of any ${kind} in this task: ${previewLabels(unknown)}. Use only supplied labels.`;
|
||||
}
|
||||
|
||||
export function describeReusedLabels(reused: readonly string[], container = 'submission'): string {
|
||||
if (reused.length === 0) return '';
|
||||
return `Each label belongs to at most one ${container}; these are reused: ${previewLabels(reused)}. Remove every duplicate claim.`;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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.
|
||||
|
||||
/** Closed Pass 1 submission schema and defensive group acceptance. */
|
||||
|
||||
import { describeReusedLabels, describeUnknownLabels } from './submit-validation.js';
|
||||
|
||||
const TOP_LEVEL_KEYS = Object.freeze(['groups'] as const);
|
||||
const GROUP_KEYS = Object.freeze(['queue_labels', 'reasoning'] as const);
|
||||
|
||||
export interface TaskFormationGroup {
|
||||
readonly queue_labels: readonly string[];
|
||||
readonly reasoning: string;
|
||||
}
|
||||
|
||||
export interface AcceptedTaskGroups {
|
||||
readonly groups: readonly TaskFormationGroup[];
|
||||
readonly rejectedGroupCount: number;
|
||||
readonly droppedUnknownLabelCount: number;
|
||||
}
|
||||
|
||||
interface ParsedGroup {
|
||||
readonly group?: TaskFormationGroup;
|
||||
readonly labels: readonly string[];
|
||||
readonly duplicateLabels: readonly string[];
|
||||
readonly closed: boolean;
|
||||
readonly structurallyValid: boolean;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
return actual.length === sortedExpected.length && actual.every((key, index) => key === sortedExpected[index]);
|
||||
}
|
||||
|
||||
function duplicateValues(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const duplicates = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) duplicates.add(value);
|
||||
seen.add(value);
|
||||
}
|
||||
return [...duplicates];
|
||||
}
|
||||
|
||||
function parseGroup(value: unknown): ParsedGroup {
|
||||
if (!isRecord(value)) {
|
||||
return { labels: [], duplicateLabels: [], closed: false, structurallyValid: false };
|
||||
}
|
||||
|
||||
const closed = hasExactKeys(value, GROUP_KEYS);
|
||||
const rawLabels = value.queue_labels;
|
||||
const labels = Array.isArray(rawLabels)
|
||||
? rawLabels.filter((label): label is string => typeof label === 'string')
|
||||
: [];
|
||||
const duplicateLabels = duplicateValues(labels);
|
||||
const structurallyValid =
|
||||
closed &&
|
||||
Array.isArray(rawLabels) &&
|
||||
labels.length === rawLabels.length &&
|
||||
labels.length >= 2 &&
|
||||
duplicateLabels.length === 0 &&
|
||||
typeof value.reasoning === 'string' &&
|
||||
value.reasoning.trim().length > 0;
|
||||
|
||||
return {
|
||||
...(structurallyValid && { group: { queue_labels: labels, reasoning: value.reasoning as string } }),
|
||||
labels,
|
||||
duplicateLabels,
|
||||
closed,
|
||||
structurallyValid,
|
||||
};
|
||||
}
|
||||
|
||||
function capturedGroups(captured: unknown): { readonly closed: boolean; readonly groups: readonly unknown[] } {
|
||||
if (!isRecord(captured) || !hasExactKeys(captured, TOP_LEVEL_KEYS) || !Array.isArray(captured.groups)) {
|
||||
return { closed: false, groups: [] };
|
||||
}
|
||||
return { closed: true, groups: captured.groups };
|
||||
}
|
||||
|
||||
/** Build the call-local schema. Both object layers reject unknown properties. */
|
||||
export function buildTaskFormationSchema(queueLabels: readonly string[]): Record<string, unknown> {
|
||||
return {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['groups'],
|
||||
properties: {
|
||||
groups: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Sets of observations that predict one exploitation attempt. Use an empty array when every observation stands alone.',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['queue_labels', 'reasoning'],
|
||||
properties: {
|
||||
queue_labels: {
|
||||
type: 'array',
|
||||
minItems: 2,
|
||||
uniqueItems: true,
|
||||
items: { type: 'string', enum: [...queueLabels] },
|
||||
description: 'Two or more distinct labels supplied in this call. A label belongs to at most one group.',
|
||||
},
|
||||
reasoning: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
description: 'Why one exploitation attempt and one verdict settle every named observation.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Explain cross-group and nonblank constraints so the model can correct a rejected call. */
|
||||
export function findTaskFormationProblems(captured: unknown, queueLabels: ReadonlySet<string>): string[] {
|
||||
const envelope = capturedGroups(captured);
|
||||
if (!envelope.closed) return ['The submission must be exactly one object with a groups array and no other fields.'];
|
||||
|
||||
const parsed = envelope.groups.map(parseGroup);
|
||||
const problems: string[] = [];
|
||||
const unknown = new Set<string>();
|
||||
const duplicateWithinGroup = new Set<string>();
|
||||
const labelUse = new Map<string, number>();
|
||||
let malformedGroups = 0;
|
||||
let undersizedGroups = 0;
|
||||
let blankReasoningGroups = 0;
|
||||
|
||||
for (let index = 0; index < envelope.groups.length; index++) {
|
||||
const raw = envelope.groups[index];
|
||||
const group = parsed[index] as ParsedGroup;
|
||||
if (!isRecord(raw) || !group.closed || !Array.isArray(raw.queue_labels)) malformedGroups++;
|
||||
if (group.labels.length < 2) undersizedGroups++;
|
||||
if (isRecord(raw) && (typeof raw.reasoning !== 'string' || raw.reasoning.trim().length === 0)) {
|
||||
blankReasoningGroups++;
|
||||
}
|
||||
for (const duplicate of group.duplicateLabels) duplicateWithinGroup.add(duplicate);
|
||||
for (const label of group.labels) {
|
||||
if (!queueLabels.has(label)) unknown.add(label);
|
||||
labelUse.set(label, (labelUse.get(label) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (malformedGroups > 0) {
|
||||
problems.push('Every group must contain exactly queue_labels and reasoning, with no other fields.');
|
||||
}
|
||||
if (undersizedGroups > 0) {
|
||||
problems.push(
|
||||
'Every group must name at least two distinct queued observations; leave single observations ungrouped.',
|
||||
);
|
||||
}
|
||||
if (duplicateWithinGroup.size > 0) {
|
||||
problems.push(`A group cannot repeat a label: ${[...duplicateWithinGroup].join(', ')}.`);
|
||||
}
|
||||
if (blankReasoningGroups > 0) {
|
||||
problems.push('Every group needs nonblank reasoning tied to one exploitation attempt and one verdict.');
|
||||
}
|
||||
|
||||
const unknownMessage = describeUnknownLabels([...unknown], 'queued finding');
|
||||
if (unknownMessage) problems.push(unknownMessage);
|
||||
|
||||
const reused = [...labelUse.entries()].filter(([, count]) => count > 1).map(([label]) => label);
|
||||
const reusedMessage = describeReusedLabels(reused, 'group');
|
||||
if (reusedMessage) problems.push(reusedMessage);
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensively accept only closed, well-formed groups. Reuse is counted across every submitted
|
||||
* group, including a group already invalid for another reason, so every claimant is discarded.
|
||||
*/
|
||||
export function acceptTaskGroups(captured: unknown, queueLabels: ReadonlySet<string>): AcceptedTaskGroups {
|
||||
const envelope = capturedGroups(captured);
|
||||
if (!envelope.closed) {
|
||||
const submittedCount = isRecord(captured) && Array.isArray(captured.groups) ? captured.groups.length : 0;
|
||||
return { groups: [], rejectedGroupCount: submittedCount, droppedUnknownLabelCount: 0 };
|
||||
}
|
||||
|
||||
const parsed = envelope.groups.map(parseGroup);
|
||||
const labelUse = new Map<string, number>();
|
||||
for (const group of parsed) {
|
||||
for (const label of group.labels) labelUse.set(label, (labelUse.get(label) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let droppedUnknownLabelCount = 0;
|
||||
const groups: TaskFormationGroup[] = [];
|
||||
for (const parsedGroup of parsed) {
|
||||
const containsUnknown = parsedGroup.labels.some((label) => !queueLabels.has(label));
|
||||
if (containsUnknown) droppedUnknownLabelCount++;
|
||||
if (!parsedGroup.structurallyValid || parsedGroup.group === undefined || containsUnknown) continue;
|
||||
if (parsedGroup.labels.some((label) => labelUse.get(label) !== 1)) continue;
|
||||
groups.push(parsedGroup.group);
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
rejectedGroupCount: envelope.groups.length - groups.length,
|
||||
droppedUnknownLabelCount,
|
||||
};
|
||||
}
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user