viveka-engine 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- viveka/__init__.py +23 -0
- viveka/capabilities/__init__.py +51 -0
- viveka/capabilities/boundaries.py +146 -0
- viveka/capabilities/classifier.py +440 -0
- viveka/capabilities/graph.py +265 -0
- viveka/capabilities/models.py +235 -0
- viveka/capabilities/rules.py +470 -0
- viveka/capabilities/vocabulary.py +125 -0
- viveka/cli/__init__.py +1 -0
- viveka/cli/app.py +138 -0
- viveka/cli/commands/__init__.py +1 -0
- viveka/cli/commands/demo.py +183 -0
- viveka/cli/commands/diagnose.py +156 -0
- viveka/cli/commands/doctor.py +211 -0
- viveka/cli/commands/evaluate.py +146 -0
- viveka/cli/commands/init_.py +106 -0
- viveka/cli/commands/inspect.py +362 -0
- viveka/cli/commands/properties.py +356 -0
- viveka/cli/commands/reduce.py +165 -0
- viveka/cli/commands/regression.py +299 -0
- viveka/cli/commands/replay.py +132 -0
- viveka/cli/commands/suggest.py +256 -0
- viveka/cli/commands/verify.py +263 -0
- viveka/cli/commands/version.py +21 -0
- viveka/cli/commands/worlds.py +197 -0
- viveka/core/__init__.py +1 -0
- viveka/core/config.py +295 -0
- viveka/core/errors.py +79 -0
- viveka/core/ids.py +79 -0
- viveka/core/paths.py +76 -0
- viveka/demo/__init__.py +19 -0
- viveka/demo/agent.py +115 -0
- viveka/demo/tools.py +150 -0
- viveka/demo/vocabulary.py +17 -0
- viveka/diagnosis/__init__.py +25 -0
- viveka/diagnosis/engine.py +334 -0
- viveka/diagnosis/models.py +72 -0
- viveka/diagnosis/store.py +96 -0
- viveka/evaluation/__init__.py +68 -0
- viveka/evaluation/binding.py +39 -0
- viveka/evaluation/contracts.py +43 -0
- viveka/evaluation/evaluators.py +392 -0
- viveka/evaluation/exceptions.py +19 -0
- viveka/evaluation/models.py +152 -0
- viveka/evaluation/reproduction.py +170 -0
- viveka/evaluation/resolvers.py +65 -0
- viveka/evaluation/store.py +102 -0
- viveka/evaluation/trace.py +43 -0
- viveka/evaluation/vocabulary.py +26 -0
- viveka/inspection/__init__.py +46 -0
- viveka/inspection/analyzer.py +98 -0
- viveka/inspection/classify.py +280 -0
- viveka/inspection/frameworks.py +125 -0
- viveka/inspection/ignore.py +96 -0
- viveka/inspection/models.py +86 -0
- viveka/inspection/python_ast.py +386 -0
- viveka/inspection/python_models.py +230 -0
- viveka/inspection/scanner.py +497 -0
- viveka/inspection/symbols.py +236 -0
- viveka/properties/__init__.py +67 -0
- viveka/properties/engine.py +376 -0
- viveka/properties/models.py +199 -0
- viveka/properties/rules.py +152 -0
- viveka/properties/store.py +150 -0
- viveka/properties/vocabulary.py +79 -0
- viveka/reasoning/__init__.py +28 -0
- viveka/reasoning/advisors/__init__.py +15 -0
- viveka/reasoning/advisors/diagnosis_enricher.py +106 -0
- viveka/reasoning/advisors/property_advisor.py +257 -0
- viveka/reasoning/advisors/world_advisor.py +187 -0
- viveka/reasoning/budget.py +57 -0
- viveka/reasoning/factory.py +113 -0
- viveka/reasoning/fake.py +92 -0
- viveka/reasoning/ollama.py +128 -0
- viveka/reasoning/openai_compat.py +144 -0
- viveka/reasoning/prompts.py +153 -0
- viveka/reasoning/provider.py +67 -0
- viveka/reasoning/schemas.py +150 -0
- viveka/reduction/__init__.py +42 -0
- viveka/reduction/engine.py +259 -0
- viveka/reduction/fingerprint.py +74 -0
- viveka/reduction/models.py +96 -0
- viveka/reduction/operators.py +145 -0
- viveka/reduction/runner.py +65 -0
- viveka/reduction/store.py +97 -0
- viveka/reduction/vocabulary.py +31 -0
- viveka/regression/__init__.py +29 -0
- viveka/regression/engine.py +262 -0
- viveka/regression/fingerprint.py +63 -0
- viveka/regression/models.py +148 -0
- viveka/regression/replay.py +214 -0
- viveka/regression/store.py +109 -0
- viveka/reporting/__init__.py +1 -0
- viveka/reporting/console.py +103 -0
- viveka/reporting/junit.py +107 -0
- viveka/runtime/__init__.py +61 -0
- viveka/runtime/adapter.py +36 -0
- viveka/runtime/collector.py +109 -0
- viveka/runtime/factory.py +44 -0
- viveka/runtime/http_adapter.py +436 -0
- viveka/runtime/mapper.py +106 -0
- viveka/runtime/mcp_adapter.py +555 -0
- viveka/runtime/models.py +209 -0
- viveka/runtime/python_adapter.py +189 -0
- viveka/runtime/vocabulary.py +67 -0
- viveka/verification/__init__.py +29 -0
- viveka/verification/engine.py +468 -0
- viveka/verification/models.py +63 -0
- viveka/verification/vocabulary.py +30 -0
- viveka/worlds/__init__.py +63 -0
- viveka/worlds/generate.py +225 -0
- viveka/worlds/models.py +208 -0
- viveka/worlds/mutations.py +771 -0
- viveka/worlds/store.py +109 -0
- viveka/worlds/vocabulary.py +205 -0
- viveka_engine-0.1.2.dist-info/METADATA +216 -0
- viveka_engine-0.1.2.dist-info/RECORD +120 -0
- viveka_engine-0.1.2.dist-info/WHEEL +4 -0
- viveka_engine-0.1.2.dist-info/entry_points.txt +2 -0
- viveka_engine-0.1.2.dist-info/licenses/LICENSE +21 -0
viveka/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VIVEKA · विवेक — Discernment
|
|
3
|
+
Property-based verification engine for AI agents.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_version() -> str:
|
|
12
|
+
"""Return the installed package version from distribution metadata.
|
|
13
|
+
|
|
14
|
+
Falls back to "0.0.0+unknown" when the package is not formally installed
|
|
15
|
+
(e.g. during editable development without ``pip install -e .``).
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
return version("viveka-engine")
|
|
19
|
+
except PackageNotFoundError:
|
|
20
|
+
return "0.0.0+unknown"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__version__ = get_version()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VIVEKA Phase 4 — Capability Model, Semantic Classification, and Trust Boundaries.
|
|
3
|
+
|
|
4
|
+
Public API:
|
|
5
|
+
- :func:`classify_capabilities` — classify Phase 3 output into capabilities.
|
|
6
|
+
- :func:`build_capability_graph` — build a static capability graph.
|
|
7
|
+
- :func:`infer_trust_boundaries` — infer trust boundaries from capabilities.
|
|
8
|
+
- :class:`CapabilityAnalysisResult` — top-level Phase 4 result model.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from viveka.capabilities.boundaries import infer_trust_boundaries
|
|
14
|
+
from viveka.capabilities.classifier import classify_capabilities
|
|
15
|
+
from viveka.capabilities.graph import build_capability_graph
|
|
16
|
+
from viveka.capabilities.models import (
|
|
17
|
+
Capability,
|
|
18
|
+
CapabilityAnalysisResult,
|
|
19
|
+
CapabilityEvidence,
|
|
20
|
+
CapabilityGraph,
|
|
21
|
+
CapabilityStatistics,
|
|
22
|
+
GraphEdge,
|
|
23
|
+
GraphNode,
|
|
24
|
+
TrustBoundary,
|
|
25
|
+
)
|
|
26
|
+
from viveka.capabilities.vocabulary import (
|
|
27
|
+
CapabilityTag,
|
|
28
|
+
Externality,
|
|
29
|
+
Reversibility,
|
|
30
|
+
SideEffect,
|
|
31
|
+
TrustRole,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Capability",
|
|
36
|
+
"CapabilityAnalysisResult",
|
|
37
|
+
"CapabilityEvidence",
|
|
38
|
+
"CapabilityGraph",
|
|
39
|
+
"CapabilityStatistics",
|
|
40
|
+
"CapabilityTag",
|
|
41
|
+
"Externality",
|
|
42
|
+
"GraphEdge",
|
|
43
|
+
"GraphNode",
|
|
44
|
+
"Reversibility",
|
|
45
|
+
"SideEffect",
|
|
46
|
+
"TrustBoundary",
|
|
47
|
+
"TrustRole",
|
|
48
|
+
"build_capability_graph",
|
|
49
|
+
"classify_capabilities",
|
|
50
|
+
"infer_trust_boundaries",
|
|
51
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Static trust boundary inference for VIVEKA Phase 4.
|
|
3
|
+
|
|
4
|
+
Infers :class:`~viveka.capabilities.models.TrustBoundary` objects from the
|
|
5
|
+
classified capabilities list. All boundaries are ``static_only = True``.
|
|
6
|
+
|
|
7
|
+
Four boundary types are inferred:
|
|
8
|
+
untrusted_ingress
|
|
9
|
+
A capability tagged as retrieval or untrusted_input feeds into the
|
|
10
|
+
agent context.
|
|
11
|
+
|
|
12
|
+
sensitive_source
|
|
13
|
+
A capability tagged as sensitive_read, secret_access, or database_read
|
|
14
|
+
is a source of privileged data.
|
|
15
|
+
|
|
16
|
+
privileged_sink
|
|
17
|
+
A capability tagged as financial_write, destructive_write,
|
|
18
|
+
shell_execution, or code_execution is a privileged action sink.
|
|
19
|
+
|
|
20
|
+
external_sink
|
|
21
|
+
A capability tagged as communication, external_write, or network_write
|
|
22
|
+
sends data outside the system boundary.
|
|
23
|
+
|
|
24
|
+
No LLMs, no network, no target code execution.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from viveka.capabilities.models import Capability, TrustBoundary
|
|
30
|
+
from viveka.capabilities.vocabulary import CapabilityTag
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Tag sets that define each boundary type
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
_UNTRUSTED_INGRESS_TAGS: frozenset[CapabilityTag] = frozenset(
|
|
37
|
+
{CapabilityTag.RETRIEVAL, CapabilityTag.UNTRUSTED_INPUT}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_SENSITIVE_SOURCE_TAGS: frozenset[CapabilityTag] = frozenset(
|
|
41
|
+
{CapabilityTag.SENSITIVE_READ, CapabilityTag.SECRET_ACCESS, CapabilityTag.DATABASE_READ}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_PRIVILEGED_SINK_TAGS: frozenset[CapabilityTag] = frozenset(
|
|
45
|
+
{
|
|
46
|
+
CapabilityTag.FINANCIAL_WRITE,
|
|
47
|
+
CapabilityTag.DESTRUCTIVE_WRITE,
|
|
48
|
+
CapabilityTag.SHELL_EXECUTION,
|
|
49
|
+
CapabilityTag.CODE_EXECUTION,
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_EXTERNAL_SINK_TAGS: frozenset[CapabilityTag] = frozenset(
|
|
54
|
+
{
|
|
55
|
+
CapabilityTag.COMMUNICATION,
|
|
56
|
+
CapabilityTag.EXTERNAL_WRITE,
|
|
57
|
+
CapabilityTag.NETWORK_WRITE,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Boundary ID counter (simple sequential for determinism in tests)
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _boundary_id(index: int) -> str:
|
|
68
|
+
return f"VBOUND-{index:04d}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Inference engine
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def infer_trust_boundaries(
|
|
77
|
+
capabilities: list[Capability],
|
|
78
|
+
) -> list[TrustBoundary]:
|
|
79
|
+
"""Infer trust boundaries from a list of capabilities.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
capabilities: The Phase 4 capability list produced by the classifier.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
A list of :class:`TrustBoundary` objects (all ``static_only = True``).
|
|
86
|
+
"""
|
|
87
|
+
boundaries: list[TrustBoundary] = []
|
|
88
|
+
counter = 0
|
|
89
|
+
|
|
90
|
+
for cap in capabilities:
|
|
91
|
+
tag_set = frozenset(cap.tags)
|
|
92
|
+
boundary_type: str | None = None
|
|
93
|
+
|
|
94
|
+
if tag_set & _UNTRUSTED_INGRESS_TAGS:
|
|
95
|
+
boundary_type = "untrusted_ingress"
|
|
96
|
+
elif tag_set & _SENSITIVE_SOURCE_TAGS:
|
|
97
|
+
boundary_type = "sensitive_source"
|
|
98
|
+
elif tag_set & _PRIVILEGED_SINK_TAGS:
|
|
99
|
+
boundary_type = "privileged_sink"
|
|
100
|
+
elif tag_set & _EXTERNAL_SINK_TAGS:
|
|
101
|
+
boundary_type = "external_sink"
|
|
102
|
+
|
|
103
|
+
if boundary_type is None:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
evidence_items = [
|
|
107
|
+
f"{e.evidence_type}:{e.value} @ {e.file_path}:{e.line}" for e in cap.evidence[:5]
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
counter += 1
|
|
111
|
+
boundary = TrustBoundary(
|
|
112
|
+
id=_boundary_id(counter),
|
|
113
|
+
boundary_type=boundary_type,
|
|
114
|
+
source=_source_for(boundary_type, cap.source_symbol),
|
|
115
|
+
destination=_destination_for(boundary_type, cap.source_symbol),
|
|
116
|
+
confidence=cap.confidence,
|
|
117
|
+
evidence=evidence_items,
|
|
118
|
+
static_only=True,
|
|
119
|
+
)
|
|
120
|
+
boundaries.append(boundary)
|
|
121
|
+
|
|
122
|
+
return boundaries
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _source_for(boundary_type: str, symbol: str) -> str:
|
|
126
|
+
"""Return the conceptual source of a trust boundary."""
|
|
127
|
+
if boundary_type == "untrusted_ingress":
|
|
128
|
+
return "external:untrusted_source"
|
|
129
|
+
if boundary_type == "sensitive_source":
|
|
130
|
+
return symbol
|
|
131
|
+
if boundary_type in ("privileged_sink", "external_sink"):
|
|
132
|
+
return "agent:context"
|
|
133
|
+
return symbol
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _destination_for(boundary_type: str, symbol: str) -> str:
|
|
137
|
+
"""Return the conceptual destination of a trust boundary."""
|
|
138
|
+
if boundary_type == "untrusted_ingress":
|
|
139
|
+
return "agent:context"
|
|
140
|
+
if boundary_type == "sensitive_source":
|
|
141
|
+
return "agent:context"
|
|
142
|
+
if boundary_type == "privileged_sink":
|
|
143
|
+
return symbol
|
|
144
|
+
if boundary_type == "external_sink":
|
|
145
|
+
return symbol
|
|
146
|
+
return symbol
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Static capability classifier for VIVEKA Phase 4.
|
|
3
|
+
|
|
4
|
+
Evaluates deterministic :class:`~viveka.capabilities.rules.CapabilityRule` s
|
|
5
|
+
against Phase 3 :class:`~viveka.inspection.python_models.StaticAnalysisResult`
|
|
6
|
+
to produce a list of :class:`~viveka.capabilities.models.Capability` objects.
|
|
7
|
+
|
|
8
|
+
Design constraints:
|
|
9
|
+
- No LLMs. No network. No target code execution.
|
|
10
|
+
- Matching is string-substring only — deliberate simplicity.
|
|
11
|
+
- ``unknown`` is always preferred over a wrong label.
|
|
12
|
+
- False-positive controls are baked in (see :func:`_is_false_positive`).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
|
|
20
|
+
from viveka.capabilities.models import Capability, CapabilityEvidence, CapabilityStatistics
|
|
21
|
+
from viveka.capabilities.rules import RULES, CapabilityRule
|
|
22
|
+
from viveka.capabilities.vocabulary import CapabilityTag
|
|
23
|
+
from viveka.core.ids import new_id
|
|
24
|
+
from viveka.inspection.python_models import (
|
|
25
|
+
PythonFunction,
|
|
26
|
+
PythonModule,
|
|
27
|
+
StaticAnalysisResult,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# False-positive guard patterns
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
# Patterns whose presence in a *function name* strongly suggests the symbol
|
|
35
|
+
# is about documentation, policy, or test — NOT a live operational capability.
|
|
36
|
+
_FP_NAME_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
|
|
37
|
+
re.compile(p, re.IGNORECASE)
|
|
38
|
+
for p in [
|
|
39
|
+
r"(policy|document|doc|readme|label|text|string|template|example|fixture|dummy|mock|fake|stub|test_|_test)",
|
|
40
|
+
]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Call expressions that look like capability-relevant calls but are actually
|
|
44
|
+
# common non-capability uses when the symbol name suggests it's a string/label.
|
|
45
|
+
_FP_SAFE_CALL_FRAGMENTS: frozenset[str] = frozenset(
|
|
46
|
+
{
|
|
47
|
+
# Common string/text operations that aren't real capabilities
|
|
48
|
+
"str.",
|
|
49
|
+
"label",
|
|
50
|
+
"template",
|
|
51
|
+
"format",
|
|
52
|
+
"print",
|
|
53
|
+
"log",
|
|
54
|
+
"logger",
|
|
55
|
+
"logging",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Environment variable patterns that are typically config reads, not secrets
|
|
60
|
+
_FP_ENV_SAFE_NAMES: frozenset[str] = frozenset(
|
|
61
|
+
{
|
|
62
|
+
"APP_ENV",
|
|
63
|
+
"ENVIRONMENT",
|
|
64
|
+
"ENV",
|
|
65
|
+
"DEBUG",
|
|
66
|
+
"LOG_LEVEL",
|
|
67
|
+
"LOG_FORMAT",
|
|
68
|
+
"PYTHONPATH",
|
|
69
|
+
"PATH",
|
|
70
|
+
"HOME",
|
|
71
|
+
"USER",
|
|
72
|
+
"LANG",
|
|
73
|
+
"LOCALE",
|
|
74
|
+
"TZ",
|
|
75
|
+
"TIMEZONE",
|
|
76
|
+
"PORT",
|
|
77
|
+
"HOST",
|
|
78
|
+
"WORKERS",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Name fragments that, in isolation, are not enough to classify as "delete"
|
|
83
|
+
# without corroborating call evidence
|
|
84
|
+
_FP_DELETE_LABEL_NAMES: frozenset[str] = frozenset(
|
|
85
|
+
{
|
|
86
|
+
"delete_label",
|
|
87
|
+
"remove_tag",
|
|
88
|
+
"delete_tag",
|
|
89
|
+
"untag",
|
|
90
|
+
"remove_flag",
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Name fragments that suggest planning / description rather than execution
|
|
95
|
+
_FP_EXECUTION_PLAN_NAMES: frozenset[str] = frozenset(
|
|
96
|
+
{
|
|
97
|
+
"execute_plan",
|
|
98
|
+
"execution_plan",
|
|
99
|
+
"plan_executor",
|
|
100
|
+
"run_plan",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_false_positive(
|
|
106
|
+
func: PythonFunction,
|
|
107
|
+
rule: CapabilityRule,
|
|
108
|
+
call_ev: list[CapabilityEvidence],
|
|
109
|
+
evidence_values: list[str],
|
|
110
|
+
) -> bool:
|
|
111
|
+
"""Return True if this (function, rule) pairing is a likely false positive.
|
|
112
|
+
|
|
113
|
+
Conservative: we only suppress when we have high confidence the match
|
|
114
|
+
is spurious. When uncertain, we keep the capability (prefer false
|
|
115
|
+
positives over false negatives for safety-oriented analysis).
|
|
116
|
+
"""
|
|
117
|
+
fname = func.name.lower()
|
|
118
|
+
qname = func.qualified_name.lower()
|
|
119
|
+
|
|
120
|
+
# Policy / documentation functions — names that strongly suggest text content
|
|
121
|
+
for pat in _FP_NAME_PATTERNS:
|
|
122
|
+
if pat.search(fname):
|
|
123
|
+
# Only allow if rule is corroborated by a real, operational call pattern
|
|
124
|
+
call_values = [e.value for e in call_ev]
|
|
125
|
+
if not any(
|
|
126
|
+
cv
|
|
127
|
+
for cv in call_values
|
|
128
|
+
if not any(safe in cv.lower() for safe in _FP_SAFE_CALL_FRAGMENTS)
|
|
129
|
+
):
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
# Secret/env access: suppress if the env var name is a common config var
|
|
133
|
+
if rule.id == "RULE-SEC-ENV-001":
|
|
134
|
+
secret_looking = False
|
|
135
|
+
for call in func.calls:
|
|
136
|
+
callee = call.callee
|
|
137
|
+
for safe in _FP_ENV_SAFE_NAMES:
|
|
138
|
+
if safe in callee.upper():
|
|
139
|
+
pass
|
|
140
|
+
else:
|
|
141
|
+
secret_looking = True
|
|
142
|
+
if not secret_looking and not evidence_values:
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
# Delete label / tag operations — name-only match without call evidence
|
|
146
|
+
if rule.id == "RULE-FS-DEL-001" and qname in _FP_DELETE_LABEL_NAMES:
|
|
147
|
+
call_values = [e.value for e in call_ev]
|
|
148
|
+
if not any(
|
|
149
|
+
pat in cv
|
|
150
|
+
for cv in call_values
|
|
151
|
+
for pat in (
|
|
152
|
+
"os.remove",
|
|
153
|
+
"os.unlink",
|
|
154
|
+
"shutil.rmtree",
|
|
155
|
+
"Path.unlink",
|
|
156
|
+
".unlink(",
|
|
157
|
+
"rmtree",
|
|
158
|
+
)
|
|
159
|
+
):
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
# "execute_plan" style names — planning functions, not code execution
|
|
163
|
+
if rule.id == "RULE-PROC-EVAL-001" and qname in _FP_EXECUTION_PLAN_NAMES:
|
|
164
|
+
call_values = [e.value for e in call_ev]
|
|
165
|
+
if not any("eval(" in cv or "exec(" in cv for cv in call_values):
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Evidence extractors
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _collect_call_evidence(
|
|
177
|
+
func: PythonFunction,
|
|
178
|
+
patterns: tuple[str, ...],
|
|
179
|
+
file_path: str,
|
|
180
|
+
) -> list[CapabilityEvidence]:
|
|
181
|
+
"""Extract CapabilityEvidence from calls inside ``func`` matching ``patterns``."""
|
|
182
|
+
evidence: list[CapabilityEvidence] = []
|
|
183
|
+
for call in func.calls:
|
|
184
|
+
for pat in patterns:
|
|
185
|
+
if pat.lower() in call.callee.lower():
|
|
186
|
+
evidence.append(
|
|
187
|
+
CapabilityEvidence(
|
|
188
|
+
evidence_type="call",
|
|
189
|
+
value=call.callee,
|
|
190
|
+
file_path=file_path,
|
|
191
|
+
line=call.line,
|
|
192
|
+
weight=0.9,
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
break # one evidence per call
|
|
196
|
+
return evidence
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _collect_import_evidence(
|
|
200
|
+
module: PythonModule,
|
|
201
|
+
patterns: tuple[str, ...],
|
|
202
|
+
) -> list[CapabilityEvidence]:
|
|
203
|
+
"""Extract CapabilityEvidence from module imports matching ``patterns``."""
|
|
204
|
+
evidence: list[CapabilityEvidence] = []
|
|
205
|
+
seen: set[str] = set()
|
|
206
|
+
for imp in module.imports:
|
|
207
|
+
for pat in patterns:
|
|
208
|
+
if pat.lower() in imp.module.lower() and imp.module not in seen:
|
|
209
|
+
seen.add(imp.module)
|
|
210
|
+
evidence.append(
|
|
211
|
+
CapabilityEvidence(
|
|
212
|
+
evidence_type="import",
|
|
213
|
+
value=imp.module,
|
|
214
|
+
file_path=module.path,
|
|
215
|
+
line=imp.line,
|
|
216
|
+
weight=0.6,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
break
|
|
220
|
+
return evidence
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _collect_name_evidence(
|
|
224
|
+
func: PythonFunction,
|
|
225
|
+
patterns: tuple[str, ...],
|
|
226
|
+
file_path: str,
|
|
227
|
+
) -> list[CapabilityEvidence]:
|
|
228
|
+
"""Extract CapabilityEvidence from function name matching ``patterns``."""
|
|
229
|
+
evidence: list[CapabilityEvidence] = []
|
|
230
|
+
for pat in patterns:
|
|
231
|
+
if pat.lower() in func.name.lower():
|
|
232
|
+
evidence.append(
|
|
233
|
+
CapabilityEvidence(
|
|
234
|
+
evidence_type="name_match",
|
|
235
|
+
value=func.name,
|
|
236
|
+
file_path=file_path,
|
|
237
|
+
line=func.line_start,
|
|
238
|
+
weight=0.4,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
break
|
|
242
|
+
return evidence
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _collect_decorator_evidence(
|
|
246
|
+
func: PythonFunction,
|
|
247
|
+
patterns: tuple[str, ...],
|
|
248
|
+
file_path: str,
|
|
249
|
+
) -> list[CapabilityEvidence]:
|
|
250
|
+
"""Extract CapabilityEvidence from function decorators matching ``patterns``."""
|
|
251
|
+
evidence: list[CapabilityEvidence] = []
|
|
252
|
+
for dec in func.decorators:
|
|
253
|
+
for pat in patterns:
|
|
254
|
+
if pat.lower() in dec.name.lower():
|
|
255
|
+
evidence.append(
|
|
256
|
+
CapabilityEvidence(
|
|
257
|
+
evidence_type="decorator",
|
|
258
|
+
value=dec.name,
|
|
259
|
+
file_path=file_path,
|
|
260
|
+
line=dec.line,
|
|
261
|
+
weight=0.7,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
break
|
|
265
|
+
return evidence
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# Confidence score
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
_CONFIDENCE_THRESHOLDS = {
|
|
273
|
+
"high": 0.7,
|
|
274
|
+
"medium": 0.35,
|
|
275
|
+
"low": 0.0,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _score_to_confidence(score: float) -> str:
|
|
280
|
+
if score >= _CONFIDENCE_THRESHOLDS["high"]:
|
|
281
|
+
return "high"
|
|
282
|
+
if score >= _CONFIDENCE_THRESHOLDS["medium"]:
|
|
283
|
+
return "medium"
|
|
284
|
+
return "low"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _evidence_score(evidence: list[CapabilityEvidence]) -> float:
|
|
288
|
+
if not evidence:
|
|
289
|
+
return 0.0
|
|
290
|
+
return min(1.0, sum(e.weight for e in evidence) / max(1, len(evidence)) + 0.1 * len(evidence))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Function-level classification
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _classify_function(
|
|
299
|
+
func: PythonFunction,
|
|
300
|
+
module: PythonModule,
|
|
301
|
+
is_tool_candidate: bool,
|
|
302
|
+
) -> list[Capability]:
|
|
303
|
+
"""Classify a single function against all rules."""
|
|
304
|
+
capabilities: list[Capability] = []
|
|
305
|
+
|
|
306
|
+
for rule in RULES:
|
|
307
|
+
if rule.id == "RULE-FALLBACK-001":
|
|
308
|
+
continue # applied separately after all other rules
|
|
309
|
+
|
|
310
|
+
# Gather evidence for this rule against this function
|
|
311
|
+
call_ev = _collect_call_evidence(func, rule.call_patterns, module.path)
|
|
312
|
+
import_ev = _collect_import_evidence(module, rule.import_patterns)
|
|
313
|
+
name_ev = _collect_name_evidence(func, rule.name_patterns, module.path)
|
|
314
|
+
dec_ev = _collect_decorator_evidence(func, rule.decorator_patterns, module.path)
|
|
315
|
+
|
|
316
|
+
# A function must have function-level evidence (call, name, or decorator).
|
|
317
|
+
# Module-level imports alone cannot attribute a capability to an arbitrary function.
|
|
318
|
+
if not (call_ev or name_ev or dec_ev):
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
all_evidence = call_ev + import_ev + name_ev + dec_ev
|
|
322
|
+
if not all_evidence:
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
# False-positive guard
|
|
326
|
+
evidence_values = [e.value for e in all_evidence]
|
|
327
|
+
if _is_false_positive(func, rule, call_ev, evidence_values):
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
score = _evidence_score(all_evidence)
|
|
331
|
+
final_confidence = _score_to_confidence(score)
|
|
332
|
+
|
|
333
|
+
cap = Capability(
|
|
334
|
+
id=new_id("VCAP"),
|
|
335
|
+
name=_capability_name(func.name, rule),
|
|
336
|
+
source_symbol=func.qualified_name,
|
|
337
|
+
source_file=module.path,
|
|
338
|
+
source_line=func.line_start,
|
|
339
|
+
tags=list(rule.produced_tags),
|
|
340
|
+
confidence=final_confidence,
|
|
341
|
+
evidence=all_evidence,
|
|
342
|
+
side_effect=rule.side_effect,
|
|
343
|
+
externality=rule.externality,
|
|
344
|
+
trust_role=rule.trust_role,
|
|
345
|
+
reversibility=rule.reversibility,
|
|
346
|
+
description=rule.explanation,
|
|
347
|
+
rule_id=rule.id,
|
|
348
|
+
)
|
|
349
|
+
capabilities.append(cap)
|
|
350
|
+
|
|
351
|
+
return capabilities
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _capability_name(func_name: str, rule: CapabilityRule) -> str:
|
|
355
|
+
"""Derive a human-readable capability name from the function name and rule."""
|
|
356
|
+
# Use the primary tag label if available
|
|
357
|
+
if rule.produced_tags and rule.produced_tags[0] != CapabilityTag.UNKNOWN:
|
|
358
|
+
tag_label = str(rule.produced_tags[0]).replace("_", " ").title()
|
|
359
|
+
return f"{func_name} [{tag_label}]"
|
|
360
|
+
return f"{func_name} [Unknown]"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# Repository-level classification
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def classify_capabilities(
|
|
369
|
+
static_result: StaticAnalysisResult,
|
|
370
|
+
) -> tuple[list[Capability], CapabilityStatistics]:
|
|
371
|
+
"""Classify capabilities from a Phase 3 :class:`StaticAnalysisResult`.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
static_result: The complete Phase 3 static analysis output.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
A tuple of ``(capabilities, statistics)``.
|
|
378
|
+
"""
|
|
379
|
+
tool_candidate_names: frozenset[str] = frozenset(
|
|
380
|
+
tc.qualified_name for tc in static_result.tool_candidates
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
all_capabilities: list[Capability] = []
|
|
384
|
+
|
|
385
|
+
for module in static_result.modules:
|
|
386
|
+
if module.parse_status != "ok":
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
# Collect all functions: top-level + class methods
|
|
390
|
+
functions: list[PythonFunction] = list(module.functions)
|
|
391
|
+
for cls in module.classes:
|
|
392
|
+
functions.extend(cls.methods)
|
|
393
|
+
|
|
394
|
+
for func in functions:
|
|
395
|
+
is_tool = func.qualified_name in tool_candidate_names
|
|
396
|
+
caps = _classify_function(func, module, is_tool)
|
|
397
|
+
all_capabilities.extend(caps)
|
|
398
|
+
|
|
399
|
+
# Deduplicate: same (symbol, rule_id) pair — keep highest-confidence one
|
|
400
|
+
deduped = _deduplicate(all_capabilities)
|
|
401
|
+
|
|
402
|
+
statistics = _compute_statistics(deduped)
|
|
403
|
+
return deduped, statistics
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _deduplicate(caps: list[Capability]) -> list[Capability]:
|
|
407
|
+
"""Remove duplicate (source_symbol, rule_id) capability pairs.
|
|
408
|
+
|
|
409
|
+
When duplicates exist, keep the one with the highest confidence.
|
|
410
|
+
"""
|
|
411
|
+
_conf_rank = {"high": 2, "medium": 1, "low": 0}
|
|
412
|
+
seen: dict[tuple[str, str | None], Capability] = {}
|
|
413
|
+
for cap in caps:
|
|
414
|
+
key = (cap.source_symbol, cap.rule_id)
|
|
415
|
+
existing = seen.get(key)
|
|
416
|
+
if existing is None or _conf_rank.get(cap.confidence, 0) > _conf_rank.get(
|
|
417
|
+
existing.confidence, 0
|
|
418
|
+
):
|
|
419
|
+
seen[key] = cap
|
|
420
|
+
return list(seen.values())
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _compute_statistics(caps: list[Capability]) -> CapabilityStatistics:
|
|
424
|
+
"""Compute aggregate statistics from a list of capabilities."""
|
|
425
|
+
by_tag: dict[str, int] = defaultdict(int)
|
|
426
|
+
by_side_effect: dict[str, int] = defaultdict(int)
|
|
427
|
+
by_trust_role: dict[str, int] = defaultdict(int)
|
|
428
|
+
|
|
429
|
+
for cap in caps:
|
|
430
|
+
for tag in cap.tags:
|
|
431
|
+
by_tag[str(tag)] += 1
|
|
432
|
+
by_side_effect[str(cap.side_effect)] += 1
|
|
433
|
+
by_trust_role[str(cap.trust_role)] += 1
|
|
434
|
+
|
|
435
|
+
return CapabilityStatistics(
|
|
436
|
+
total_capabilities=len(caps),
|
|
437
|
+
by_tag=dict(by_tag),
|
|
438
|
+
by_side_effect=dict(by_side_effect),
|
|
439
|
+
by_trust_role=dict(by_trust_role),
|
|
440
|
+
)
|