Repository navigation
Expand file tree
/
Copy pathresolve_config.py
More file actions
2152 lines (1945 loc) · 98.3 KB
/
Copy pathresolve_config.py
File metadata and controls
2152 lines (1945 loc) · 98.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Resolve assessment flags into the run configuration.
``resolve_assessment()`` is the only entry point. The controller calls it once
per admitted scope and writes the result to ``$OUTPUT_DIR/.skill-config.json``
for downstream scripts. Each per-topic resolver (requirements, YAML, depth,
reasoning models, architect review, paths, business context) is a pure function
with unit tests. Invalid or conflicting flags raise ``SystemExit`` with the
reason.
"""
from __future__ import annotations
# Loading this file by path must resolve the same packages as imports from scripts/.
import sys as _sys
from pathlib import Path as _Path
if not __package__:
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
import argparse
import json
import os
import re
import secrets
import subprocess
from pathlib import Path
from typing import Any, Optional
# ---------------------------------------------------------------------------
# Model matrix — mirrors SKILL.md "Reasoning Model Resolution → Mode matrix"
# ---------------------------------------------------------------------------
# Canonical reasoning tiers. ``haiku-economy`` was the original name for the
# economy tier, but it oversells the Haiku routing: STRIDE/triage/merger stay
# on Sonnet (see below). The honest name is ``sonnet-economy`` — a Sonnet
# reasoning floor with economy periphery. ``haiku-economy`` is retained as a
# backward-compatible input alias (CLI flags, stored .skill-config.json,
# recorded fixtures) and is normalised to the canonical name on the way in.
REASONING_ALIASES = {"haiku-economy": "sonnet-economy"}
def canonical_reasoning_model(mode):
"""Map a deprecated reasoning-tier alias to its canonical name.
Pass-through for unknown / canonical values and for ``None``.
"""
if mode is None:
return mode
return REASONING_ALIASES.get(mode, mode)
MODEL_MATRIX = {
"sonnet": {
"stride": "sonnet",
"triage": "sonnet",
"merger": "sonnet",
},
# opus-cheap: explicit opt-in only — no longer any depth's default since
# 2026-06 (was the standard/thorough default). It puts Opus on the cheap
# merger phase while leaving STRIDE — the value-creating reasoning — on
# Sonnet, which is the inverse of where Opus pays off. Kept as a valid
# --reasoning-model choice for a deliberate middle ground.
"opus-cheap": {
"stride": "sonnet",
# triage stays on Sonnet: scripts/validators/triage_validate_ratings.py provides a
# deterministic floor (outlier thresholds, completeness counts, CVSS
# eligibility); the agent only does judgment-call validation on top.
# Opus here was overkill — Sonnet handles the structured input.
"triage": "sonnet",
"merger": "opus",
},
"opus": {
"stride": "opus",
"triage": "opus",
"merger": "opus",
},
# sonnet-economy: threat reasoning (STRIDE/triage/merger) stays on the
# Sonnet TIER — it is NOT routed to Haiku (the principle: the main value
# contributor must not be downgraded). The Haiku lever kicks in for the
# more-deterministic agents via EXTENDED_MODEL_MATRIX (context-resolver,
# recon-scanner, qa-routine, config-scanner). Quick mode additionally gets
# a STRIDE depth reduction via resolve_stride_profile(). (Alias: haiku-economy.)
#
# COST PIN (2026-07-04): the concrete Sonnet is pinned here to ``claude-sonnet-4-6``
# rather than the bare alias ``"sonnet"`` (→ now Sonnet 5). Reason: same
# price/token, but the 4.6 tokenizer counts the same text in ~30% FEWER tokens
# than Sonnet 5 (+ no adaptive thinking by default) → the subagent half of a
# full scan drops from ~$60 back to the ~$37 level, without violating the
# Sonnet-tier principle (4.6 IS Sonnet, just the previous version).
# sonnet-economy is the default for quick/standard, so it applies automatically
# for all plugin users. Opt in to Sonnet 5 (quality): ``--reasoning-model
# sonnet``. Exact pin per stage: ``APPSEC_{STRIDE,TRIAGE,MERGER}_MODEL=…``.
# Deprecation note: when claude-sonnet-4-6 is retired, bump it here.
"sonnet-economy": {
"stride": "claude-sonnet-4-6",
"triage": "claude-sonnet-4-6",
"merger": "claude-sonnet-4-6",
},
}
# Routing for agents beyond stride/triage/merger.
# Key: (reasoning_tier, depth) → agent_type → model.
# Default routing applies for sonnet/opus-cheap/opus (= unchanged from today).
# sonnet-economy has depth-specific routing.
HAIKU = "haiku"
SONNET = "sonnet"
# Explicit latest-Sonnet pin used for the `standard` quality buy-back (2026-07-05):
# the aggregation/judgment stages measurably improve on Sonnet 5 (see
# docs/model-selection.md "Benchmarks"). Kept as an exact id, not the `sonnet`
# alias, so a headless run pins the version rather than following the session.
# The pin follows the latest Sonnet release; Sonnet 5.5 has no A/B of its own.
SONNET5 = "claude-sonnet-5-5"
# The economy pin; a combined assessment moves STRIDE off it (MD-9).
SONNET46 = "claude-sonnet-4-6"
EXTENDED_MODEL_MATRIX: dict[tuple[str, str], dict[str, str]] = {
# sonnet-economy: extended-agent routing.
#
# context-resolver, recon-scanner, config-scanner are deterministic
# tasks (extraction / grep / rule-engine application against a YAML
# check catalog) — they run on Haiku at every depth.
# qa_routine moves up to Sonnet at thorough because the document is
# bigger and more cross-references need reconciling.
("sonnet-economy", "quick"): {
"context_resolver": HAIKU,
"recon_scanner": HAIKU,
"qa_routine": HAIKU,
"qa_content": SONNET,
"config_scanner": HAIKU,
"orchestrator": SONNET,
# renderer + abuse_verifier follow the `sonnet` alias → host session at
# every depth (no depth variation, like orchestrator). Manual pin via
# APPSEC_RENDERER_MODEL / APPSEC_ABUSE_VERIFIER_MODEL for quality buy-back
# (Sonnet-5) or cost (4.6) without moving the whole session.
#
# evidence_verifier stays on SONNET at every tier and depth — including
# sonnet-economy, where every other extraction role drops to Haiku.
# Verified/refuted/ambiguous discrimination requires reading code
# semantics at the cited line; Haiku regressed to stamping every sampled
# finding `ambiguous` (0 verified / 0 refuted, ~57ms batch), which
# cascaded into an all-review, zero-P1 Mitigation Register. Override via
# APPSEC_EVIDENCE_VERIFIER_MODEL only with that failure mode in mind.
"renderer": SONNET,
"abuse_verifier": SONNET,
"evidence_verifier": SONNET,
},
("sonnet-economy", "standard"): {
"context_resolver": HAIKU,
"recon_scanner": HAIKU,
"qa_routine": HAIKU,
"qa_content": SONNET,
"config_scanner": HAIKU,
"orchestrator": SONNET,
"renderer": SONNET,
"abuse_verifier": SONNET,
"evidence_verifier": SONNET,
},
("sonnet-economy", "thorough"): {
"context_resolver": HAIKU,
"recon_scanner": HAIKU,
"qa_routine": SONNET,
"qa_content": SONNET,
"config_scanner": HAIKU,
"orchestrator": SONNET,
"renderer": SONNET,
"abuse_verifier": SONNET,
"evidence_verifier": SONNET,
},
}
# Default routing for sonnet/opus-cheap/opus.
# Even on the default tiers, context-resolver, recon-scanner and
# config-scanner are routed to Haiku — these phases are pure extraction /
# grep / lookup-table application and need no Sonnet floor.
# To override, set APPSEC_RECON_SCANNER_MODEL etc.
_DEFAULT_EXTENDED_ROUTING = {
"context_resolver": HAIKU,
"recon_scanner": HAIKU,
"qa_routine": SONNET,
"qa_content": SONNET,
"config_scanner": HAIKU,
"orchestrator": SONNET,
"renderer": SONNET,
"abuse_verifier": SONNET,
"evidence_verifier": SONNET,
}
# Quick-mode STRIDE depth reduction. The model stays Sonnet — only the
# task scope is reduced. Applies independently of the reasoning tier
# when assessment_depth == "quick".
#
# P3 (A6) re-balance: ``skip_evidence_excerpt`` was demoted from True to
# False. The evidence excerpt is a yaml-side string trim of the threat's
# ``scenario`` field — its emission costs the STRIDE analyzer nothing
# extra (the field is read by the renderer's ``_synthesise_label`` helper,
# not generated as new prose), and dropping it stripped the Linked-Threats
# columns and §8 Finding column of every descriptive substring. The
# remaining flags (skip_verification_greps, max_threats_per_category,
# skip_code_examples, skip_cvss_scoring, turn_budget_hard_cap) preserve
# the real token-budget reductions while the report regains its
# scannable-evidence content.
QUICK_STRIDE_PROFILE = {
"skip_verification_greps": True, # A
"max_threats_per_category": 1, # B (was 2 — quick is a triage pass.
# Keep only the top-severity threat
# per STRIDE category per component.
# CRITICAL/HIGH-SAFE: the analyzer never
# drops a Critical or High to honour this cap
# — see appsec-stride-analyzer-v2.md
# Quick-mode table exception.)
"skip_code_examples": False, # C (R9 — was True; flipped 2026-05.
# User feedback: mitigations without
# code hints are not actionable.
# Marginal cost ≈ 200-400 output
# tokens per mitigation × ~20
# mitigations ≈ <1 min added to a
# ~33-min Quick run. Real turn-
# budget savings come from A/B/E/F.)
"skip_evidence_excerpt": False, # D (P3 — was True; cheap to keep, restores §8 evidence)
"skip_cvss_scoring": True, # E
"turn_budget_hard_cap": 25, # F (was 40)
}
# NOTE: the per-depth STRIDE-component COUNT (formerly "components": 3/5/8) was
# removed 2026-06-07. The analyzed-component set is no longer a hard-coded number
# — it is derived from criteria (exposure / ci-cd / crown-jewel) by
# orchestrator/build_stride_dispatch_manifest.py:select_stride_components(). Depth changes only
# the STRIDE turn budget + diagram/QA depth here; WHICH components get analyzed is
# decided by the criteria predicate over the full inventory in .components.json.
DEPTH_PARAMS = {
"quick": {
"simple": 10,
"moderate": 15,
"complex": 20,
"diagrams": "minimal",
"qa": "core",
"qa_label": "skipped",
"max_repair_iterations": 1,
},
"standard": {
"simple": 15,
"moderate": 22,
"complex": 31,
"diagrams": "standard",
"qa": "full",
"qa_label": "full",
"max_repair_iterations": 1,
},
"thorough": {
"simple": 20,
"moderate": 28,
"complex": 35,
"diagrams": "extended",
"qa": "extended",
"qa_label": "extended",
"max_repair_iterations": 3,
},
}
# Optional trust-boundary context is capped independently from the STRIDE turn
# budgets above. The context preparer imports this constant; neither depth
# parameters nor the dispatch builder's fallback should duplicate it.
BOUNDARY_CANDIDATE_LIMITS = {"quick": 2, "standard": 4, "thorough": 6}
# ``max_repair_iterations`` — the hard cap on the Stage-3 QA / Stage-4 architect
# Re-Render Loop. At quick/standard the loop is a SINGLE quick-fix pass (one
# repair attempt, then fail-closed `exit 2` if the contract still does not hold —
# never ship an invalid report). thorough keeps the historical budget of 3.
# The legacy re-render loop that read it is gone; no current producer reads
# the key. NOTE: this key is intentionally NOT mirrored into
# build_stride_dispatch_manifest._FALLBACK_DEPTH_PARAMS — that fallback only
# tracks the per-complexity STRIDE turn budgets (simple/moderate/complex).
# Operational safety ceiling on the number of components dispatched to STRIDE —
# a merge/turn-budget guard, NOT the selection count. The criteria predicate is
# the selector; this only caps a pathologically large inventory (auth/frontend/
# exposed are never dropped — the ceiling lifts and logs EXPOSURE_CAP_LIFT).
# Depth-independent: the same operational limit regardless of assessment depth.
# Recon deliberately has no hard inventory cap. When positive selection
# criteria yield more than this ceiling (for example 50 exposed services), the
# ceiling lifts and bounded dispatch waves control execution pressure instead.
STRIDE_COMPONENT_CEILING = 10
# Maximum number of per-component STRIDE agents dispatched in one foreground
# wave. Selection remains uncapped for exposed/security-relevant components;
# this bounds only concurrent execution pressure. Override for a particular
# host with APPSEC_STRIDE_CONCURRENCY (1..5). The upper bound keeps one
# context-v2 wave within the 64-artifact immediate receipt-verification cap:
# bundle, component plan, optional business and architecture projections,
# optional repository projection, and six independently receipted component
# security projections, plus the effective-plan receipt.
STRIDE_DISPATCH_CONCURRENCY = 5
STRIDE_DISPATCH_CONCURRENCY_MAX = 5
# ---------------------------------------------------------------------------
# Conflict detection — runs before any resolution
# ---------------------------------------------------------------------------
CONFLICT_PAIRS: list[tuple[str, str, str]] = [
# (attr_a, attr_b, error_message)
("yaml", "no_yaml", "--yaml and --no-yaml cannot be used together."),
("requirements", "no_requirements", "--requirements and --no-requirements cannot be used together."),
("full", "incremental", "--full and --incremental cannot be used together."),
("full", "resume", "--full starts a complete assessment; --resume continues a checkpoint. Pick one."),
("rebuild", "incremental", "--rebuild discards all prior state; --incremental requires it. Pick one."),
("rebuild", "resume", "--rebuild wipes the checkpoint file; --resume needs it. Pick one."),
("rerender", "full", "--rerender re-renders the existing assessment; --full rebuilds it. Pick one."),
("rerender", "incremental", "--rerender reuses Stage-1 outputs; --incremental re-analyzes a delta. Pick one."),
("rerender", "rebuild", "--rerender reuses existing artifacts; --rebuild wipes them. Pick one."),
("rerender", "resume", "--rerender starts a fresh render; --resume continues a checkpoint. Pick one."),
("context", "rerender", "--rerender re-renders an existing analysis; --context only affects a new one."),
("context", "resume", "--resume continues a run whose context was already resolved; --context comes too late."),
(
"architect_review",
"no_architect_review",
"--architect-review and --no-architect-review cannot be used together.",
),
("quick", "thorough", "--quick and --thorough cannot be used together."),
("enrich_arch", "no_enrich_arch", "--enrich-arch and --no-enrich-arch cannot be used together."),
("abuse_cases", "no_abuse_cases", "--abuse-cases and --no-abuse-cases cannot be used together."),
(
"abuse_case_file",
"no_abuse_cases",
"--abuse-case-file names cases to verify; --no-abuse-cases turns verification off. Pick one.",
),
(
"only_abuse_case",
"no_abuse_cases",
"--only-abuse-case selects cases to verify; --no-abuse-cases turns verification off. Pick one.",
),
("cheap_stride", "no_cheap_stride", "--cheap-stride and --no-cheap-stride cannot be used together."),
]
def detect_conflicts(ns: argparse.Namespace) -> Optional[str]:
for a, b, msg in CONFLICT_PAIRS:
if getattr(ns, a, False) and getattr(ns, b, False):
return msg
slug = getattr(ns, "slug", None)
# "__auto__" is the bare-flag sentinel (random slug generated at resolve
# time) — only an explicit user value is validated for filename-safety.
if slug is not None and slug != "__auto__" and not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", slug):
return "--slug must be 1-64 filename-safe characters ([A-Za-z0-9._-]); got: " + repr(slug)
# `--slug` takes an OPTIONAL value, so argparse refuses to consume a token
# that starts with '-'. In `--slug --keep-runtime-files my-label` the label
# therefore lands in the positional `scope` and the run silently stamps a
# random slug instead — the user only finds out ~3 hours later, from the
# filenames (juice-shop 2026-08-02: shipped as `threat-model-3225.*`).
# Refuse the ambiguity instead of guessing: a lone filename-safe token next
# to a bare `--slug` is a swallowed value, not a scope phrase (those are
# prose, and read as several words).
if slug == "__auto__":
scope_words = [str(s) for s in (getattr(ns, "scope", None) or [])]
if len(scope_words) == 1 and re.fullmatch(r"[A-Za-z0-9._-]{1,64}", scope_words[0]):
return (
f"--slug was given no value, so {scope_words[0]!r} was read as scope and the "
f"deliverables would be stamped with a random slug. Write the value directly "
f"after the flag (--slug {scope_words[0]}), or drop the trailing argument to "
f"keep the generated slug."
)
return None
# ---------------------------------------------------------------------------
# Per-resolver functions — each is pure, takes the namespace + external
# state, and returns a dict of resolved values to merge into the config.
# ---------------------------------------------------------------------------
def resolve_write_yaml(ns: argparse.Namespace) -> dict:
if ns.no_yaml:
return {"write_yaml": False, "write_yaml_label": "disabled (--no-yaml)"}
if ns.yaml:
return {"write_yaml": True, "write_yaml_label": "enabled (--yaml)"}
return {"write_yaml": True, "write_yaml_label": "enabled (default)"}
def resolve_requirements(ns: argparse.Namespace, config_enabled: bool) -> dict:
"""Resolution order: --no-requirements → --requirements → config → false.
A URL override attached to --requirements is also captured. Quick
assessment depth flips the default to disabled unless the user
explicitly opted in via --requirements.
"""
if ns.no_requirements:
return {
"check_requirements": False,
"requirements_url_override": None,
"requirements_label": "disabled (--no-requirements)",
}
if ns.requirements is not None:
return {
"check_requirements": True,
"requirements_url_override": ns.requirements or None,
"requirements_label": (
f"enabled (--requirements {ns.requirements})" if ns.requirements else "enabled (--requirements)"
),
}
if config_enabled:
return {"check_requirements": True, "requirements_url_override": None, "requirements_label": "enabled (config)"}
return {"check_requirements": False, "requirements_url_override": None, "requirements_label": "disabled (config)"}
def resolve_assessment_depth(ns: argparse.Namespace) -> dict:
depth = ns.assessment_depth or "standard"
params = DEPTH_PARAMS[depth]
label = (
f"{depth} (components: criteria-selected, STRIDE turns: "
f"{params['simple']}/{params['moderate']}/{params['complex']}, "
f"diagrams: {params['diagrams']}, QA: {params['qa_label']})"
)
return {
"assessment_depth": depth,
"max_stride_components": STRIDE_COMPONENT_CEILING,
"max_boundary_candidates_per_component": BOUNDARY_CANDIDATE_LIMITS[depth],
"stride_turns_simple": params["simple"],
"stride_turns_moderate": params["moderate"],
"stride_turns_complex": params["complex"],
"diagram_depth": params["diagrams"],
"qa_depth": params["qa"],
"max_repair_iterations": params["max_repair_iterations"],
"depth_label": label,
# Severity floor for the canonical register (2026-06-26). Default
# 'medium' drops Low/Informational — low-risk findings are noise in a
# threat model. Override with --register-severity-floor low to keep them.
"register_severity_floor": (getattr(ns, "register_severity_floor", None) or "medium"),
}
def resolve_stride_concurrency() -> dict:
raw = os.environ.get("APPSEC_STRIDE_CONCURRENCY")
if raw is None or not raw.strip():
return {"stride_concurrency": STRIDE_DISPATCH_CONCURRENCY}
try:
value = int(raw)
except ValueError as exc:
raise SystemExit(
f"Error: APPSEC_STRIDE_CONCURRENCY must be an integer between 1 and {STRIDE_DISPATCH_CONCURRENCY_MAX}"
) from exc
if not 1 <= value <= STRIDE_DISPATCH_CONCURRENCY_MAX:
raise SystemExit(f"Error: APPSEC_STRIDE_CONCURRENCY must be between 1 and {STRIDE_DISPATCH_CONCURRENCY_MAX}")
return {"stride_concurrency": value}
# Artifact contracts a context-v2 run reconstructs its successor action from.
# Persisted with the run so a resume can prove the on-disk artifacts still match
# the producer that wrote them instead of re-deriving the answer from the
# current environment.
CONTEXT_V2_ARTIFACT_SCHEMA_VERSIONS = {
"orchestration-action": 1,
"route-inventory": 1,
"recon-patterns": 1,
"recon-summary-context": 1,
"architecture-route-context": 1,
"recon-signals": 2,
"actors-merged-static": 1,
"actors-resolved": 1,
"trust-boundary-assessment-input": 1,
"trust-boundaries": 2,
"stride-analyst-context": 1,
"stride-dispatch-manifest": 2,
"stride-dispatch-waves": 2,
"stride-evidence-bundle": 1,
"stride-component-context-plan": 1,
"stride-component-business-context": 1,
"stride-component-architecture-context": 1,
"stride-component-security-context": 1,
"stride-component-repository-roots": 1,
"stride-repository-registry": 1,
"context-effective-plan": 1,
"context-effective-plan-receipt": 1,
"merge-candidates": 1,
"merge-review-context": 1,
"merge-decisions": 2,
"threats-merged": 1,
"evidence-verification": 1,
"evidence-verifier-context": 1,
"post-stride-generated-threats": 1,
"post-stride-proposed-mitigations": 1,
"abuse-case-verifier-context": 1,
"abuse-case-deriver-context": 1,
"triage-flags": 2,
"mitigation-overrides": 1,
"tier-root-causes": 1,
}
def resolve_runtime_generation() -> dict:
"""Return the sole producer generation for every new invocation.
Runtime support is decided by the controller before it mutates run state.
Persisting one generation even for a rejected configuration makes it
impossible for an environment override or a special mode to revive the
removed producer.
"""
return {
"runtime_generation": "context-v2",
"runtime_generation_label": "context-v2 (single runtime)",
"runtime_artifact_schema_versions": dict(CONTEXT_V2_ARTIFACT_SCHEMA_VERSIONS),
}
def resolve_evidence_verifier_cap(ns: argparse.Namespace, depth: str) -> dict:
"""Bound Phase 10a work while preserving every Critical finding.
The verifier prompt applies this cap *after* selecting all Criticals, so a
repository with more Criticals than the configured cap still verifies each
of them. The default keeps normal standard runs bounded without reducing
the high-risk coverage that makes the phase useful.
"""
requested = getattr(ns, "evidence_verifier_cap", None)
if requested is not None:
return {
"evidence_verifier_max_findings": requested,
"evidence_verifier_cap_label": f"{requested} (--evidence-verifier-cap)",
}
defaults = {"quick": 20, "standard": 30, "thorough": 100}
cap = defaults[depth]
return {
"evidence_verifier_max_findings": cap,
"evidence_verifier_cap_label": f"{cap} (depth default)",
}
def resolve_abuse_case_verification(ns: argparse.Namespace, depth: str) -> dict:
"""Resolve ``skip_abuse_case_verification`` + a human-readable label.
Default: abuse-case verification runs at standard/thorough and is SKIPPED
at quick depth — the fast mode drops the per-candidate verifier fan-out
(matcher + haiku/sonnet verifiers + chain fold), the most expensive part of
Stage 1d. ``--abuse-cases`` forces it on at any depth (incl. quick);
``--no-abuse-cases`` forces it off at any depth (incl. standard/thorough).
``--abuse-case-file`` / ``--only-abuse-case`` name the cases this run is to
verify, so they force it on the same way — otherwise the named cases would
be resolved into a stage that never runs. Conflicting combinations are
rejected up front (see ``CONFLICT_PAIRS``).
"""
if getattr(ns, "no_abuse_cases", False):
return {"skip_abuse_case_verification": True, "abuse_case_label": "skipped (--no-abuse-cases)"}
if getattr(ns, "abuse_cases", False):
return {"skip_abuse_case_verification": False, "abuse_case_label": "enabled (--abuse-cases)"}
if getattr(ns, "abuse_case_file", None) or getattr(ns, "only_abuse_case", None):
return {
"skip_abuse_case_verification": False,
"abuse_case_label": "enabled (per-scan case selection)",
}
if depth == "quick":
return {"skip_abuse_case_verification": True, "abuse_case_label": "skipped (auto - quick depth)"}
return {"skip_abuse_case_verification": False, "abuse_case_label": "enabled"}
def resolve_cheap_stride(ns: argparse.Namespace, depth: str) -> dict:
"""Resolve ``cheap_stride`` + a human-readable label.
Default: ON at quick and standard depth, OFF at thorough. Screening depth is
a cost/pacing tier for components that are *proven* side-of-the-house, and
``build_stride_dispatch_manifest._cheap_stride_target`` is what decides that:
auth, frontend, LLM, internet-exposed, file-upload, realtime, data-store and
core-backend components always keep full depth, and so does anything whose
reachability is unknown. Only ci-cd and provably-internal components qualify,
which is why this is safe to default — it can only ever trim the tail.
Thorough keeps full depth everywhere by definition: it is the mode a user
picks when depth matters more than cost.
``--cheap-stride`` forces it on at any depth (incl. thorough);
``--no-cheap-stride`` forces it off at any depth. The two flags are mutually
exclusive (see ``CONFLICT_PAIRS``).
"""
if getattr(ns, "no_cheap_stride", False):
return {"cheap_stride": False, "cheap_stride_label": "off (--no-cheap-stride)"}
if getattr(ns, "cheap_stride", False):
return {"cheap_stride": True, "cheap_stride_label": "on (--cheap-stride)"}
if depth == "thorough":
return {"cheap_stride": False, "cheap_stride_label": "off (auto - thorough depth)"}
return {"cheap_stride": True, "cheap_stride_label": f"on (auto - {depth} depth)"}
# B2c — repo-size auto-cap thresholds.
# Source: 2026-04-27 juice-shop incident — 5 STRIDE components once consumed
# 50+ min in Phase 9 on cold caches and never finished, so large repos were
# capped to 3 components. 2026-06-02 reversal: dropping components is the WRONG
# cost/time lever — it created whole-component BLIND SPOTS (the 2026-06-02
# cap-to-3 run missed the b2b-api RCE and the data-tier entirely, 23 threats vs
# 42, and never even catalogued the §7.2 MFA surface). On large repos we now
# keep the default reasoning tier (Opus on STRIDE/triage/merger — large repos
# are where Opus pays off; the B2d economy auto-downgrade was removed 2026-06)
# and analyze ALL components — the Phase-9 heartbeat watchdog
# (runtime/skill_watchdog.py: stride-stale 900s, per-component
# timeout 480s) bounds any cold-cache hang instead of pre-emptively dropping
# attack surface. Cost is controlled by the tier, not by blind spots.
# User-facing text derived from this threshold states RUNTIME ("longer run
# expected"), never repo SIZE. Size is judged on one axis only — the much higher
# orchestrator-window threshold below — otherwise the pre-flight box contradicts
# itself (a 650-file repo is "normal-sized" there and would read "large" here).
LARGE_REPO_SOURCE_FILE_THRESHOLD = 400
# Orchestrator (session-model) recommendation threshold — DISTINCT from and much
# higher than LARGE_REPO_SOURCE_FILE_THRESHOLD above (which only flags "longer run").
# The orchestrator holds the largest resident context; on a *very* large repo a
# small-window session model (Sonnet 4.6 in the harness) risks mid-run compaction,
# which can drop finalization steps. At/above this source-file count we RECOMMEND the
# large-window Sonnet 5 as the SESSION model (higher cost, for more compaction
# headroom); below it we recommend Sonnet 4.6 (much cheaper, only very limited
# orchestrator benefit). Advisory only — the user always chooses (see the
# interactive prompt in the compact full runtime).
# CALIBRATION CAVEAT: this is a coarse heuristic, not a measured compaction point.
# We have one calibration repo (Juice-Shop, ~650 source files by the count below);
# 2500 is simply a margin well above it so a normal app recommends 4.6. Source-file
# count is a *proxy* — the orchestrator's resident context is really driven by the
# analyzed component count and finding volume (roughly bounded, not linear in files),
# so the true compaction point does not track this number precisely. Kept because it
# is the only cheap signal available pre-recon, and the recommendation is fail-safe
# (defaults to the cheap model, user overrides).
ORCHESTRATOR_SONNET5_FILE_THRESHOLD = 2500
SOURCE_FILE_EXTENSIONS = (
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".py",
".go",
".java",
".kt",
".rb",
".php",
".cs",
".rs",
".swift",
".scala",
".cpp",
".c",
".h",
)
def _count_source_files(repo_root: Path) -> int:
"""Count tracked source files via git ls-files. Returns 0 on any error.
Cheap (one subprocess) and bounded (timeout 5 s). Falls back to 0 — which
skips the cap heuristic — rather than failing the resolution.
"""
try:
r = subprocess.run(
["git", "-C", str(repo_root), "ls-files"],
capture_output=True,
text=True,
timeout=5,
)
if r.returncode != 0:
return 0
count = 0
for line in r.stdout.splitlines():
if line.endswith(SOURCE_FILE_EXTENSIONS):
count += 1
return count
except (OSError, subprocess.SubprocessError):
return 0
def resolve_repo_size_cap(cfg: dict, repo_root: Path, *, source_count: int | None = None) -> dict:
"""B2c — flag large standard-depth repos (informational only).
Triggers only when:
* assessment_depth is "standard" (the default tier — so the user did
not explicitly opt for a deeper analysis)
* source-file count > LARGE_REPO_SOURCE_FILE_THRESHOLD
* the user did not pass --assessment-depth thorough explicitly
On trigger: set repo_size_capped=True and append a marker to depth_label.
No component count is reduced — the analyzed set is criteria-derived
(2026-06-07). Since 2026-06 the flag is **purely informational** (a
"large repo → longer run" heads-up); it no longer downgrades the reasoning
tier (the B2d auto-downgrade was removed — large repos are where Opus
reasoning pays off).
Returns the patched cfg slice (dict — not the full cfg) so the caller
can `cfg.update(...)` it.
"""
if cfg.get("assessment_depth") != "standard":
return {}
src_count = _count_source_files(repo_root) if source_count is None else source_count
if src_count <= LARGE_REPO_SOURCE_FILE_THRESHOLD:
return {}
# Large repo: flag it (informational). We do NOT drop components —
# dropping creates whole-component blind spots (2026-06-02); since
# 2026-06-07 the analyzed set is criteria-derived, not a number, so there
# is no component count to reduce here. Since 2026-06 the flag no longer
# downgrades the reasoning tier either (B2d removed) — it is purely a
# "large repo → longer run" heads-up.
new_label = (
f"{cfg['assessment_depth']} (criteria-selected components — "
f"{src_count} source files → longer run expected, "
f"reasoning on default tier, "
f"STRIDE turns: {cfg['stride_turns_simple']}/"
f"{cfg['stride_turns_moderate']}/{cfg['stride_turns_complex']}, "
f"diagrams: {cfg['diagram_depth']}, QA: {cfg['qa_depth']})"
)
return {
"depth_label": new_label,
"repo_size_capped": True,
"repo_size_source_files": src_count,
}
def recommend_orchestrator_model(src_count: int) -> dict:
"""Advisory session-model recommendation from repo source-file count.
The orchestrator = the CC session model; the plugin cannot switch it. This
only RECOMMENDS — the skill surfaces it and the user chooses (a divergent
choice needs a session restart because a running loop cannot change its own
model). Recommends Sonnet 5.5 only for *very* large repos (window safety);
otherwise Sonnet 4.6 (much cheaper, only very limited orchestrator benefit).
"""
if src_count >= ORCHESTRATOR_SONNET5_FILE_THRESHOLD:
model = SONNET5
reason = (
f"very large repo ({src_count} source files >= "
f"{ORCHESTRATOR_SONNET5_FILE_THRESHOLD}) — the orchestrator accumulates a "
f"large resident context; Sonnet 5.5's larger window reduces the risk of "
f"mid-run compaction (higher cost). Note: this is a coarse file-count "
f"heuristic — a margin above our single calibration repo, not a measured "
f"compaction threshold"
)
else:
model = "claude-sonnet-4-6"
reason = (
f"normal-sized repo ({src_count} source files < "
f"{ORCHESTRATOR_SONNET5_FILE_THRESHOLD}) — Sonnet 4.6 as the session model "
f"has significantly lower cost than a Sonnet-5 or Opus session, which "
f"bring only very limited benefit on the orchestrator role at this repo "
f"size, and its window is sufficient here (Sonnet 5.5 is recommended once a "
f"repo crosses {ORCHESTRATOR_SONNET5_FILE_THRESHOLD} files)"
)
return {
"orchestrator_recommended_model": model,
"orchestrator_recommendation_reason": reason,
"orchestrator_recommendation_repo_files": src_count,
}
def resolve_reasoning_model(ns: argparse.Namespace, depth: str) -> dict:
"""Resolution order: env-vars → --reasoning-model → depth default.
Defaults per depth:
• quick → sonnet-economy (deterministic-leaning agents on Haiku;
Reasoning core stays on Sonnet)
• standard → sonnet-economy (the everyday default — favour cost;
Reasoning core on Sonnet)
• thorough → opus (premium tier: STRIDE/triage/merger on Opus)
Override with ``--reasoning-model sonnet`` to keep all agents on
Sonnet at quick (pre-2026-05 behaviour). Env vars
(APPSEC_STRIDE_MODEL / APPSEC_TRIAGE_MODEL / APPSEC_MERGER_MODEL)
take highest precedence for fine-grained overrides.
The ``sonnet-economy`` tier keeps STRIDE/triage/merger on the Sonnet
TIER (Threat-Reasoning is the tool's primary value contribution and
must not be downgraded to Haiku). Since 2026-07-04 the concrete Sonnet
is cost-pinned to ``claude-sonnet-4-6`` (same tier, ~30% fewer tokens
than Sonnet 5 at equal per-token price — see the MODEL_MATRIX comment).
Opt into Sonnet 5 with ``--reasoning-model sonnet``; pin an exact model
per stage with ``APPSEC_{STRIDE,TRIAGE,MERGER}_MODEL``. The Haiku savings
come from the deterministic-leaning agents (context-resolver,
recon-scanner, qa-routine fixes, config-scanner) — see
resolve_extended_models.
"""
# Step 1: pick the base mode (normalising deprecated aliases).
if ns.reasoning_model:
mode = canonical_reasoning_model(ns.reasoning_model)
elif depth in ("quick", "standard"):
# quick + standard default: sonnet-economy. A clean A/B (Juice Shop,
# 2026-06-23 — see docs/analysis/analysis-model-placement-...§10) showed
# Opus reasoning costs ~+$10.77 (+36%) over sonnet-economy with NO
# measurable quality/coverage gain; the earlier "Opus cheaper/better for
# STRIDE" thesis was refuted (Opus-STRIDE never actually ran in the
# measurements behind it). standard is the everyday default → favour
# cost. Opt into Opus with --reasoning-model opus, or upgrade just the
# severity stage with --triage-model opus.
mode = "sonnet-economy"
else:
# thorough default: Opus on STRIDE/triage/merger — the premium tier
# where full reasoning depth + architect review justify the cost.
# Opt out with --reasoning-model sonnet-economy (or --no-opus).
mode = "opus"
models = dict(MODEL_MATRIX[mode])
# Quality buy-back at the everyday `standard` default (2026-07-05): upgrade the
# aggregation/judgment stages — triage (severity calibration) and merger (dedup)
# — to Sonnet 5, where a Juice-Shop A/B measured a real gain (see
# docs/model-selection.md "Benchmarks"). STRIDE stays on 4.6: Sonnet 5 REGRESSED
# discovery recall. `quick` keeps the all-4.6 economy floor; `thorough` is Opus.
# Env/CLI overrides below still win. Caveat: an explicit version id only takes
# effect on the headless / hybrid-dispatch path — an interactive run inherits the
# session model regardless (the bare `sonnet` alias resolves to the session).
if mode == "sonnet-economy" and depth == "standard":
models["triage"] = SONNET5
models["merger"] = SONNET5
# Step 2: env var override (over the matrix default).
env_map = {
"stride": "APPSEC_STRIDE_MODEL",
"triage": "APPSEC_TRIAGE_MODEL",
"merger": "APPSEC_MERGER_MODEL",
}
for k, env in env_map.items():
if os.environ.get(env):
models[k] = os.environ[env]
# Step 3: explicit per-stage CLI flags — highest precedence (most explicit,
# per-run intent). They win over both the matrix and the env vars. --no-opus
# still clamps Opus→Sonnet later in resolve() via apply_opus_ban().
cli_map = {
"stride": getattr(ns, "stride_model", None),
"triage": getattr(ns, "triage_model", None),
"merger": getattr(ns, "merger_model", None),
}
for k, v in cli_map.items():
if v:
models[k] = v
label = f"{mode} (STRIDE: {models['stride']}, triage: {models['triage']}, merger: {models['merger']})"
return {
"reasoning_model": mode,
"stride_model": models["stride"],
"triage_model": models["triage"],
"merger_model": models["merger"],
"reasoning_label": label,
}
def resolve_extended_models(reasoning_mode: str, depth: str) -> dict:
"""Resolve models for agents beyond the stride/triage/merger triplet.
Covers context-resolver, recon-scanner, qa-reviewer (split into
routine + content), config-scanner, and the orchestrator.
Routing rationale (verified against agent specs):
- ``context_resolver``, ``recon_scanner``, ``config_scanner`` are
deterministic tasks (file extraction / grep + classification /
YAML-rule application) → **always Haiku**, regardless of depth or
reasoning tier.
- ``qa_content`` does invariant / contract reasoning → always Sonnet.
- ``qa_routine`` is mechanical (link patches, anchor renames) →
Haiku at quick + standard, Sonnet at thorough where the document
is bigger and cross-references are denser.
- ``orchestrator`` runs Phase 3-8 + 11 (architecture, walkthroughs,
composer) — never on Haiku.
Env vars (APPSEC_<AGENT>_MODEL) override per-agent for ad-hoc
debugging or to force a specific tier on a specific phase.
"""
reasoning_mode = canonical_reasoning_model(reasoning_mode)
if reasoning_mode == "sonnet-economy":
models = dict(EXTENDED_MODEL_MATRIX[("sonnet-economy", depth)])
else:
models = dict(_DEFAULT_EXTENDED_ROUTING)
# Extended Sonnet-tier routing. The bare `sonnet` alias would silently follow the
# host session (Opus / 4.6 / 5), so we replace it with concrete ids per role.
# Skipped for the explicit `sonnet` tier (`--reasoning-model sonnet`), which keeps
# the alias so the user gets latest Sonnet. The ORCHESTRATOR is never touched — it
# IS the session model, which the plugin cannot set (hardcoding would make the
# routing table lie). NOTE: these explicit-id pins bite on the headless/hybrid
# path; an interactive run's subagents inherit the session model. Env overrides win.
if reasoning_mode != "sonnet":
# renderer + abuse-case verifier are the quality-showcase stages (MS / CISO
# framing; verdict decisiveness — 4.6 punts to `inconclusive`): latest Sonnet
# (Sonnet 5.5) at standard AND thorough, cheapest 4.6 only at the quick tier.
showcase = SONNET5 if depth in ("standard", "thorough") else "claude-sonnet-4-6"
for _k in ("renderer", "abuse_verifier"):
if models.get(_k) == SONNET:
models[_k] = showcase
# qa_content + qa_routine are mechanical / contract stages → concrete 4.6
# wherever they would otherwise be the bare alias (Haiku stays Haiku).
for _k in ("qa_content", "qa_routine"):
if models.get(_k) == SONNET:
models[_k] = "claude-sonnet-4-6"
env_map = {
"context_resolver": "APPSEC_CONTEXT_RESOLVER_MODEL",
"recon_scanner": "APPSEC_RECON_SCANNER_MODEL",
"qa_routine": "APPSEC_QA_ROUTINE_MODEL",
"qa_content": "APPSEC_QA_CONTENT_MODEL",
"config_scanner": "APPSEC_CONFIG_SCANNER_MODEL",
"orchestrator": "APPSEC_ORCHESTRATOR_MODEL",
"renderer": "APPSEC_RENDERER_MODEL",
"abuse_verifier": "APPSEC_ABUSE_VERIFIER_MODEL",
"evidence_verifier": "APPSEC_EVIDENCE_VERIFIER_MODEL",
}
for k, env in env_map.items():
if os.environ.get(env):
models[k] = os.environ[env]
return {
"context_resolver_model": models["context_resolver"],
"recon_scanner_model": models["recon_scanner"],
"qa_routine_model": models["qa_routine"],
"qa_content_model": models["qa_content"],
"config_scanner_model": models["config_scanner"],
"orchestrator_model": models["orchestrator"],
"renderer_model": models["renderer"],
"abuse_verifier_model": models["abuse_verifier"],
"evidence_verifier_model": models["evidence_verifier"],
}
_OPUS_TOKEN = "opus" # matches "opus", "opus-cheap", "claude-opus-4-7"
_MODEL_FIELDS = (
"stride_model",
"triage_model",
"merger_model",
"architect_model",
"orchestrator_model",
"context_resolver_model",
"recon_scanner_model",
"qa_routine_model",
"qa_content_model",
"config_scanner_model",
"renderer_model",
"abuse_verifier_model",
)
def apply_opus_ban(cfg: dict, disable_opus: bool) -> dict:
"""Single, non-bypassable ceiling: rewrite every Opus selection to Sonnet.
Runs LAST in resolve() — after env overrides,
--reasoning-model resolution and the org-profile merge. That ordering
is what makes the ceiling
non-bypassable: an explicit ``--reasoning-model opus`` or an
``APPSEC_*_MODEL=claude-opus-4-7`` env override are both clamped here
after they have been applied.
The ban is sourced from (CLI ``--no-opus``) OR (env
``APPSEC_DISABLE_OPUS``) OR (org-profile ``policy.disable_opus``) — any
one tightens, none loosens. Idempotent and a no-op when ``disable_opus``
is False.
Always records ``cfg["opus_disabled"]`` so the summary renderer and
downstream consumers can see the ceiling state. Returns a patch dict to
``cfg.update()``.
"""
cfg["opus_disabled"] = bool(disable_opus)
if not disable_opus:
return {}
patch: dict = {}
# 1) Tier coercion — drives labels and any downstream "is this opus?" check.
if cfg.get("reasoning_model") in ("opus", "opus-cheap"):
patch["reasoning_model"] = "sonnet"
# 2) Field clamp — any *_model carrying an opus token -> sonnet. Catches
# both the short alias ("opus") and the full id ("claude-opus-4-7"),
# and covers env-var per-agent overrides on the extended agents.
for f in _MODEL_FIELDS:
v = cfg.get(f)
if v and _OPUS_TOKEN in str(v).lower():
patch[f] = "sonnet"
# 3) Labels — make the downgrade visible, never silent.
base_mode = patch.get("reasoning_model", cfg.get("reasoning_model"))
patch["reasoning_label"] = f"{base_mode} (no-opus: Opus→Sonnet ceiling active)"
if cfg.get("architect_review"):
patch["architect_label"] = "enabled (sonnet, no-opus ceiling)"
return patch
def resolve_stride_profile(reasoning_mode: str, depth: str, stride_cap: int | None = None) -> dict:
"""Return the STRIDE-analyzer depth profile.
The STRIDE depth-reduction (A-F) is gated on
``reasoning_mode == sonnet-economy`` AND ``depth == quick``.
Both conditions must hold to keep behaviour predictable for users
who pick sonnet-economy at standard/thorough (no STRIDE reduction
there) and for users who explicitly pick a non-economy tier at quick
(e.g. ``--reasoning-model sonnet``, which preserves the pre-2026-05
"Sonnet everywhere at quick" behaviour).
Since 2026-05 ``sonnet-economy`` is also the default at quick depth,
so an unflagged ``--assessment-depth quick`` invocation activates
the A-F profile automatically — no extra flag required.
Quick + sonnet-economy applies:
A. Skip verification greps
B. Cap threats per STRIDE category at 2 (was "2-5")
C. Omit code_example field in remediation
D. Omit evidence excerpt (file:line stays)
E. Skip CVSS v4.0 scoring
F. Lower TURN_BUDGET hard cap from 40 to 25
The model itself stays Sonnet — only the task scope is reduced.
``stride_cap`` (the opt-in ``--stride-cap N`` flag) is an ORTHOGONAL
cost lever: when set (>=1) it injects ``max_threats_per_category = N``
into the emitted profile **at any depth** without enabling the other