traceforge-toolkit 0.1.0__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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Shell command classification via tree-sitter AST analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import tree_sitter as ts
|
|
12
|
+
import tree_sitter_bash as tsbash
|
|
13
|
+
|
|
14
|
+
from traceforge.classify.core import (
|
|
15
|
+
Classification,
|
|
16
|
+
Effect,
|
|
17
|
+
PhaseSegment,
|
|
18
|
+
Structure,
|
|
19
|
+
aggregate_effect,
|
|
20
|
+
)
|
|
21
|
+
from traceforge.classify.coding import (
|
|
22
|
+
CodingAction,
|
|
23
|
+
CodingMechanism,
|
|
24
|
+
ShellStructure,
|
|
25
|
+
)
|
|
26
|
+
from traceforge.classify.rules import (
|
|
27
|
+
SHELL_DELIVERY,
|
|
28
|
+
SHELL_SETUP,
|
|
29
|
+
SHELL_VERIFICATION,
|
|
30
|
+
ShellActivity,
|
|
31
|
+
classify_binary,
|
|
32
|
+
effect_for_binary,
|
|
33
|
+
match_rule,
|
|
34
|
+
)
|
|
35
|
+
from traceforge.classify.workflow import Phase
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from traceforge.classify.config import ClassificationEngine
|
|
39
|
+
|
|
40
|
+
_BASH_LANGUAGE = ts.Language(tsbash.language())
|
|
41
|
+
_parser = ts.Parser(_BASH_LANGUAGE)
|
|
42
|
+
_Q_COMMANDS = ts.Query(_BASH_LANGUAGE, "(command) @cmd")
|
|
43
|
+
|
|
44
|
+
_IS_ENV_VAR = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*=")
|
|
45
|
+
_STRIP_BINARY_EXT = re.compile(r"\.(exe|cmd|bat|ps1|sh)$", re.IGNORECASE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── AST helpers ──
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _inside_command_substitution(node: ts.Node) -> bool:
|
|
52
|
+
p = node.parent
|
|
53
|
+
while p:
|
|
54
|
+
if p.type == "command_substitution":
|
|
55
|
+
return True
|
|
56
|
+
p = p.parent
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _words_from_command_node(node: ts.Node) -> list[str]:
|
|
61
|
+
"""Extract word tokens from a command AST node, including quoted strings."""
|
|
62
|
+
_EXTRACTABLE = frozenset({"word", "raw_string", "string", "simple_expansion", "concatenation"})
|
|
63
|
+
words: list[str] = []
|
|
64
|
+
for child in node.children:
|
|
65
|
+
if child.type == "command_name":
|
|
66
|
+
for sub in child.children:
|
|
67
|
+
if sub.type in _EXTRACTABLE and sub.text:
|
|
68
|
+
# Strip surrounding quotes from string literals
|
|
69
|
+
text = sub.text.decode("utf-8")
|
|
70
|
+
if len(text) >= 2 and text[0] in ("'", '"') and text[-1] == text[0]:
|
|
71
|
+
text = text[1:-1]
|
|
72
|
+
words.append(text)
|
|
73
|
+
elif child.type in _EXTRACTABLE and child.text:
|
|
74
|
+
text = child.text.decode("utf-8")
|
|
75
|
+
if len(text) >= 2 and text[0] in ("'", '"') and text[-1] == text[0]:
|
|
76
|
+
text = text[1:-1]
|
|
77
|
+
words.append(text)
|
|
78
|
+
return words
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Wrapper flags that consume exactly one argument (e.g., sudo -u <user>)
|
|
82
|
+
_WRAPPER_FLAGS_WITH_ARG: dict[str, frozenset[str]] = {
|
|
83
|
+
"sudo": frozenset(
|
|
84
|
+
{"-u", "--user", "-g", "--group", "-C", "-D", "-R", "--role", "-t", "--type"}
|
|
85
|
+
),
|
|
86
|
+
"env": frozenset({"-u", "--unset", "-S", "--split-string"}),
|
|
87
|
+
"timeout": frozenset({"--signal", "-s", "-k", "--kill-after"}),
|
|
88
|
+
"nice": frozenset({"-n", "--adjustment"}),
|
|
89
|
+
"ionice": frozenset({"-c", "--class", "-n", "--classdata", "-p", "--pid"}),
|
|
90
|
+
"chroot": frozenset({"--userspec", "--groups"}),
|
|
91
|
+
"runuser": frozenset({"-u", "--user", "-g", "--group", "-G", "--supp-group"}),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _looks_like_command(token: str) -> bool:
|
|
96
|
+
return bool(token) and not token[0].isdigit() and "/" not in token
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _unwrap_binary(
|
|
100
|
+
words: list[str],
|
|
101
|
+
engine: ClassificationEngine,
|
|
102
|
+
) -> tuple[str, str | None, list[str], frozenset[str]]:
|
|
103
|
+
"""Extract binary, subcmd, flags, and wrapper capabilities from a word list.
|
|
104
|
+
|
|
105
|
+
Returns (binary, subcmd, flags, wrapper_caps) where wrapper_caps contains
|
|
106
|
+
capabilities derived from stripped wrappers (e.g., elevated_privilege from sudo).
|
|
107
|
+
"""
|
|
108
|
+
transparent = engine.transparent_wrappers
|
|
109
|
+
idx = 0
|
|
110
|
+
wrapper_caps: set[str] = set()
|
|
111
|
+
|
|
112
|
+
while idx < len(words) and _IS_ENV_VAR.match(words[idx]):
|
|
113
|
+
idx += 1
|
|
114
|
+
|
|
115
|
+
limit = 5
|
|
116
|
+
while limit > 0 and idx < len(words):
|
|
117
|
+
binary = _STRIP_BINARY_EXT.sub("", os.path.basename(words[idx]).lower())
|
|
118
|
+
|
|
119
|
+
if binary not in transparent:
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
# Track capabilities from wrappers
|
|
123
|
+
if binary == "sudo":
|
|
124
|
+
wrapper_caps.add("elevated_privilege")
|
|
125
|
+
|
|
126
|
+
known_arg_flags = _WRAPPER_FLAGS_WITH_ARG.get(binary, frozenset())
|
|
127
|
+
idx += 1
|
|
128
|
+
# Skip env-style VAR=val assignments
|
|
129
|
+
while idx < len(words) and "=" in words[idx] and not words[idx].startswith("-"):
|
|
130
|
+
idx += 1
|
|
131
|
+
# Skip wrapper flags
|
|
132
|
+
while idx < len(words) and words[idx].startswith("-"):
|
|
133
|
+
flag = words[idx]
|
|
134
|
+
idx += 1
|
|
135
|
+
# Flags with = already have their value inline (e.g., --timeout=5)
|
|
136
|
+
if "=" in flag:
|
|
137
|
+
continue
|
|
138
|
+
# Known flags that take an argument: always consume next token
|
|
139
|
+
if flag in known_arg_flags:
|
|
140
|
+
if idx < len(words) and not words[idx].startswith("-"):
|
|
141
|
+
idx += 1
|
|
142
|
+
continue
|
|
143
|
+
# Short flags that take an argument: consume next token if it
|
|
144
|
+
# doesn't look like another flag or a command name
|
|
145
|
+
if (
|
|
146
|
+
idx < len(words)
|
|
147
|
+
and not words[idx].startswith("-")
|
|
148
|
+
and "=" not in words[idx]
|
|
149
|
+
and not _looks_like_command(words[idx])
|
|
150
|
+
):
|
|
151
|
+
idx += 1
|
|
152
|
+
# For 'timeout', consume the duration positional before the command
|
|
153
|
+
if binary == "timeout" and idx < len(words) and not words[idx].startswith("-"):
|
|
154
|
+
# Duration arg: skip if it looks like a number/duration (e.g., 5, 5s, 10m)
|
|
155
|
+
token = words[idx]
|
|
156
|
+
if token and (token[0].isdigit() or token in ("infinity",)):
|
|
157
|
+
idx += 1
|
|
158
|
+
limit -= 1
|
|
159
|
+
|
|
160
|
+
if idx >= len(words):
|
|
161
|
+
return "", None, [], frozenset(wrapper_caps)
|
|
162
|
+
|
|
163
|
+
binary = _STRIP_BINARY_EXT.sub("", os.path.basename(words[idx]).lower())
|
|
164
|
+
|
|
165
|
+
remaining = words[idx + 1 :]
|
|
166
|
+
subcmd = remaining[0] if remaining and not remaining[0].startswith("-") else None
|
|
167
|
+
flags = [w for w in remaining if w.startswith("-")]
|
|
168
|
+
return binary, subcmd, flags, frozenset(wrapper_caps)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── Public API ──
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _extract_commands_from_ast(command: str) -> list[str]:
|
|
175
|
+
"""Extract individual command texts from a shell string via tree-sitter query."""
|
|
176
|
+
if not command or not command.strip():
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
tree = _parser.parse(command.encode("utf-8"))
|
|
180
|
+
cursor = ts.QueryCursor(_Q_COMMANDS)
|
|
181
|
+
matches = cursor.matches(tree.root_node)
|
|
182
|
+
|
|
183
|
+
commands: list[str] = []
|
|
184
|
+
for _pat, captures in matches:
|
|
185
|
+
for node in captures.get("cmd", []):
|
|
186
|
+
if _inside_command_substitution(node):
|
|
187
|
+
continue
|
|
188
|
+
text = node.text.decode("utf-8").strip() if node.text else ""
|
|
189
|
+
if text:
|
|
190
|
+
commands.append(text)
|
|
191
|
+
|
|
192
|
+
if not commands:
|
|
193
|
+
commands.append(command.strip())
|
|
194
|
+
|
|
195
|
+
return commands
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True)
|
|
199
|
+
class _CommandClassification:
|
|
200
|
+
"""Per-command classification result (internal helper)."""
|
|
201
|
+
|
|
202
|
+
binary: str
|
|
203
|
+
activity: ShellActivity
|
|
204
|
+
action: str
|
|
205
|
+
scope: str
|
|
206
|
+
role: str
|
|
207
|
+
phase: str
|
|
208
|
+
capabilities: frozenset[str]
|
|
209
|
+
effect: str | None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def classify_single_command(
|
|
213
|
+
binary: str,
|
|
214
|
+
subcmd: str | None,
|
|
215
|
+
flags: list[str],
|
|
216
|
+
words: list[str] | None = None,
|
|
217
|
+
*,
|
|
218
|
+
engine: ClassificationEngine,
|
|
219
|
+
) -> _CommandClassification:
|
|
220
|
+
"""Classify a single binary invocation into its dimensions.
|
|
221
|
+
|
|
222
|
+
Shared across bash/powershell/cmd classifiers so all produce consistent
|
|
223
|
+
Classification objects with phase_map.
|
|
224
|
+
"""
|
|
225
|
+
activity = classify_binary(binary, subcmd, flags, words, engine=engine)
|
|
226
|
+
rule = match_rule(binary, subcmd, flags, engine=engine)
|
|
227
|
+
|
|
228
|
+
bi = engine.binary_info
|
|
229
|
+
act_to_action = engine.activity_to_action
|
|
230
|
+
act_to_scope = engine.activity_to_scope
|
|
231
|
+
act_to_phase = engine.activity_to_phase
|
|
232
|
+
git_subcmd_action = engine.git_subcmd_actions
|
|
233
|
+
verif_role_action = engine.verification_role_actions
|
|
234
|
+
|
|
235
|
+
# Role (rule-based, fallback to binary info)
|
|
236
|
+
cmd_role = ""
|
|
237
|
+
if rule and rule.role:
|
|
238
|
+
cmd_role = rule.role
|
|
239
|
+
else:
|
|
240
|
+
info = bi.get(binary)
|
|
241
|
+
if info:
|
|
242
|
+
cmd_role = info.role
|
|
243
|
+
|
|
244
|
+
# Action: rule override > per-subcommand precision > activity default
|
|
245
|
+
cmd_action = ""
|
|
246
|
+
if rule and rule.action:
|
|
247
|
+
cmd_action = rule.action
|
|
248
|
+
elif activity == SHELL_DELIVERY and binary == "git" and subcmd:
|
|
249
|
+
cmd_action = git_subcmd_action.get(subcmd, CodingAction.COMMIT)
|
|
250
|
+
elif activity == SHELL_VERIFICATION and cmd_role:
|
|
251
|
+
cmd_action = verif_role_action.get(cmd_role, CodingAction.TEST)
|
|
252
|
+
else:
|
|
253
|
+
cmd_action = act_to_action.get(activity, "")
|
|
254
|
+
|
|
255
|
+
# Scope: rule override > activity default
|
|
256
|
+
cmd_scope = ""
|
|
257
|
+
if rule and rule.scope:
|
|
258
|
+
cmd_scope = rule.scope
|
|
259
|
+
else:
|
|
260
|
+
cmd_scope = act_to_scope.get(activity, "")
|
|
261
|
+
|
|
262
|
+
# Phase: rule override > activity default
|
|
263
|
+
cmd_phase = ""
|
|
264
|
+
if rule and rule.phase:
|
|
265
|
+
cmd_phase = rule.phase
|
|
266
|
+
else:
|
|
267
|
+
cmd_phase = act_to_phase.get(activity, Phase.IMPLEMENTATION)
|
|
268
|
+
|
|
269
|
+
# Capabilities
|
|
270
|
+
caps: set[str] = set()
|
|
271
|
+
info = bi.get(binary)
|
|
272
|
+
if info and info.network:
|
|
273
|
+
caps.add("network_outbound")
|
|
274
|
+
if binary == "git" and subcmd in ("push", "pull", "fetch", "clone"):
|
|
275
|
+
caps.add("network_outbound")
|
|
276
|
+
if activity == SHELL_SETUP:
|
|
277
|
+
caps.add("filesystem_write")
|
|
278
|
+
caps.add("network_outbound")
|
|
279
|
+
if binary == "sudo":
|
|
280
|
+
caps.add("elevated_privilege")
|
|
281
|
+
|
|
282
|
+
# Effect: rule override > effect_overrides > binary_info
|
|
283
|
+
if rule and rule.effect:
|
|
284
|
+
effect = rule.effect
|
|
285
|
+
else:
|
|
286
|
+
effect = effect_for_binary(binary, subcmd, flags, engine=engine, all_words=words)
|
|
287
|
+
|
|
288
|
+
return _CommandClassification(
|
|
289
|
+
binary=binary,
|
|
290
|
+
activity=activity,
|
|
291
|
+
action=cmd_action,
|
|
292
|
+
scope=cmd_scope,
|
|
293
|
+
role=cmd_role,
|
|
294
|
+
phase=cmd_phase,
|
|
295
|
+
capabilities=frozenset(caps),
|
|
296
|
+
effect=effect,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def build_classification_from_commands(
|
|
301
|
+
command_results: list[_CommandClassification],
|
|
302
|
+
structure: frozenset[str] = frozenset(),
|
|
303
|
+
shell_dialect: str | None = None,
|
|
304
|
+
) -> Classification:
|
|
305
|
+
"""Build a Classification from a list of per-command results.
|
|
306
|
+
|
|
307
|
+
Shared across bash/powershell/cmd classifiers.
|
|
308
|
+
"""
|
|
309
|
+
all_roles: set[str] = set()
|
|
310
|
+
all_actions: set[str] = set()
|
|
311
|
+
all_scopes: set[str] = set()
|
|
312
|
+
all_capabilities: set[str] = {"subprocess"}
|
|
313
|
+
all_effects: list[str] = []
|
|
314
|
+
all_binaries: list[str] = []
|
|
315
|
+
phase_actions: dict[str, set[str]] = defaultdict(set)
|
|
316
|
+
phase_scopes: dict[str, set[str]] = defaultdict(set)
|
|
317
|
+
phase_roles: dict[str, set[str]] = defaultdict(set)
|
|
318
|
+
|
|
319
|
+
for cmd in command_results:
|
|
320
|
+
all_binaries.append(cmd.binary)
|
|
321
|
+
if cmd.role:
|
|
322
|
+
all_roles.add(cmd.role)
|
|
323
|
+
if cmd.action:
|
|
324
|
+
all_actions.add(cmd.action)
|
|
325
|
+
if cmd.scope:
|
|
326
|
+
all_scopes.add(cmd.scope)
|
|
327
|
+
all_capabilities.update(cmd.capabilities)
|
|
328
|
+
if cmd.effect:
|
|
329
|
+
all_effects.append(cmd.effect)
|
|
330
|
+
|
|
331
|
+
# Group by phase
|
|
332
|
+
if cmd.action:
|
|
333
|
+
phase_actions[cmd.phase].add(cmd.action)
|
|
334
|
+
if cmd.scope:
|
|
335
|
+
phase_scopes[cmd.phase].add(cmd.scope)
|
|
336
|
+
if cmd.role:
|
|
337
|
+
phase_roles[cmd.phase].add(cmd.role)
|
|
338
|
+
|
|
339
|
+
# Aggregate effect
|
|
340
|
+
agg_effect = aggregate_effect(*all_effects) if all_effects else None
|
|
341
|
+
|
|
342
|
+
# Filesystem capabilities from aggregate effect
|
|
343
|
+
if agg_effect in (Effect.MUTATING, Effect.DESTRUCTIVE):
|
|
344
|
+
all_capabilities.add("filesystem_write")
|
|
345
|
+
else:
|
|
346
|
+
all_capabilities.add("filesystem_read")
|
|
347
|
+
|
|
348
|
+
# Build phase_map
|
|
349
|
+
all_phases = set(phase_actions.keys()) | set(phase_scopes.keys()) | set(phase_roles.keys())
|
|
350
|
+
phase_map = tuple(
|
|
351
|
+
PhaseSegment(
|
|
352
|
+
phase=phase,
|
|
353
|
+
actions=frozenset(phase_actions.get(phase, set())),
|
|
354
|
+
scopes=frozenset(phase_scopes.get(phase, set())),
|
|
355
|
+
roles=frozenset(phase_roles.get(phase, set())),
|
|
356
|
+
)
|
|
357
|
+
for phase in sorted(all_phases)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
return Classification(
|
|
361
|
+
mechanism=CodingMechanism.PROCESS_SHELL,
|
|
362
|
+
effect=agg_effect,
|
|
363
|
+
scope=frozenset(all_scopes),
|
|
364
|
+
role=frozenset(all_roles),
|
|
365
|
+
action=frozenset(all_actions),
|
|
366
|
+
capability=frozenset(all_capabilities),
|
|
367
|
+
structure=structure,
|
|
368
|
+
shell_dialect=shell_dialect,
|
|
369
|
+
binaries=tuple(dict.fromkeys(all_binaries)),
|
|
370
|
+
phase_map=phase_map,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _detect_structure(tree: ts.Tree) -> frozenset[str]:
|
|
375
|
+
"""Detect structural properties from the AST."""
|
|
376
|
+
structures: set[str] = set()
|
|
377
|
+
root = tree.root_node
|
|
378
|
+
|
|
379
|
+
for child in root.children:
|
|
380
|
+
if child.type == "list":
|
|
381
|
+
structures.add(Structure.SEQUENTIAL)
|
|
382
|
+
elif child.type == "pipeline":
|
|
383
|
+
if child.child_count > 1:
|
|
384
|
+
structures.add(ShellStructure.PIPED)
|
|
385
|
+
|
|
386
|
+
_check_tree_structure(root, structures)
|
|
387
|
+
return frozenset(structures)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
_OUTPUT_REDIRECT_OPS = frozenset({">", ">>", ">&", "&>", "&>>"})
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _has_output_redirect(node: ts.Node) -> bool:
|
|
394
|
+
"""Check if a redirected_statement contains an output redirect (not input-only)."""
|
|
395
|
+
for child in node.children:
|
|
396
|
+
if child.type == "file_redirect":
|
|
397
|
+
for op in child.children:
|
|
398
|
+
if op.type in _OUTPUT_REDIRECT_OPS:
|
|
399
|
+
return True
|
|
400
|
+
elif child.type == "heredoc_redirect":
|
|
401
|
+
# Heredocs are input, not output
|
|
402
|
+
continue
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _check_tree_structure(node: ts.Node, structures: set[str]) -> None:
|
|
407
|
+
"""Recursively check for structural patterns."""
|
|
408
|
+
if node.type == "redirected_statement":
|
|
409
|
+
if _has_output_redirect(node):
|
|
410
|
+
structures.add(ShellStructure.REDIRECTED)
|
|
411
|
+
elif node.type == "pipeline" and node.child_count > 1:
|
|
412
|
+
structures.add(ShellStructure.PIPED)
|
|
413
|
+
elif node.type in ("if_statement", "case_statement"):
|
|
414
|
+
structures.add(Structure.CONDITIONAL)
|
|
415
|
+
|
|
416
|
+
for child in node.children:
|
|
417
|
+
_check_tree_structure(child, structures)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def classify_shell(
|
|
421
|
+
command: str,
|
|
422
|
+
*,
|
|
423
|
+
engine: ClassificationEngine,
|
|
424
|
+
) -> Classification:
|
|
425
|
+
"""Classify a bash shell command into a Classification object.
|
|
426
|
+
|
|
427
|
+
For compound commands (e.g. `pytest && git push`), each command is classified
|
|
428
|
+
independently. Actions, scopes, and roles are grouped by derived phase in
|
|
429
|
+
`phase_map`, and also unioned into the top-level aggregate sets.
|
|
430
|
+
"""
|
|
431
|
+
if not command or not command.strip():
|
|
432
|
+
return Classification(
|
|
433
|
+
mechanism=CodingMechanism.PROCESS_SHELL,
|
|
434
|
+
effect=None,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
tree = _parser.parse(command.encode("utf-8"))
|
|
438
|
+
cursor = ts.QueryCursor(_Q_COMMANDS)
|
|
439
|
+
matches = cursor.matches(tree.root_node)
|
|
440
|
+
|
|
441
|
+
if not matches:
|
|
442
|
+
return Classification(
|
|
443
|
+
mechanism=CodingMechanism.PROCESS_SHELL,
|
|
444
|
+
effect=None,
|
|
445
|
+
capability=frozenset({"subprocess"}),
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
command_results: list[_CommandClassification] = []
|
|
449
|
+
all_wrapper_caps: set[str] = set()
|
|
450
|
+
|
|
451
|
+
for _pattern_idx, captures in matches:
|
|
452
|
+
for node in captures.get("cmd", []):
|
|
453
|
+
if _inside_command_substitution(node):
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
words = _words_from_command_node(node)
|
|
457
|
+
if not words:
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
binary, subcmd, flags, wrapper_caps = _unwrap_binary(words, engine=engine)
|
|
461
|
+
all_wrapper_caps.update(wrapper_caps)
|
|
462
|
+
if not binary:
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
cmd_cls = classify_single_command(binary, subcmd, flags, words, engine=engine)
|
|
466
|
+
command_results.append(cmd_cls)
|
|
467
|
+
|
|
468
|
+
if not command_results:
|
|
469
|
+
return Classification(
|
|
470
|
+
mechanism=CodingMechanism.PROCESS_SHELL,
|
|
471
|
+
effect=None,
|
|
472
|
+
capability=frozenset({"subprocess"} | all_wrapper_caps),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
structure = _detect_structure(tree)
|
|
476
|
+
cls = build_classification_from_commands(command_results, structure, "bash")
|
|
477
|
+
# Merge wrapper capabilities (e.g., elevated_privilege from sudo)
|
|
478
|
+
extra_caps: set[str] = set(all_wrapper_caps)
|
|
479
|
+
extra_effect: str | None = None
|
|
480
|
+
# Redirections (>, >>, 2>) indicate filesystem writes
|
|
481
|
+
if ShellStructure.REDIRECTED in structure:
|
|
482
|
+
extra_caps.add("filesystem_write")
|
|
483
|
+
extra_effect = "mutating"
|
|
484
|
+
if extra_caps or extra_effect:
|
|
485
|
+
from traceforge.classify.core import aggregate_effect as _agg
|
|
486
|
+
|
|
487
|
+
merged_effect = cls.effect
|
|
488
|
+
if extra_effect:
|
|
489
|
+
effects = [e for e in (cls.effect, extra_effect) if e]
|
|
490
|
+
merged_effect = _agg(*effects) if effects else cls.effect
|
|
491
|
+
cls = Classification(
|
|
492
|
+
mechanism=cls.mechanism,
|
|
493
|
+
effect=merged_effect,
|
|
494
|
+
scope=cls.scope,
|
|
495
|
+
role=cls.role,
|
|
496
|
+
action=cls.action,
|
|
497
|
+
capability=cls.capability | frozenset(extra_caps),
|
|
498
|
+
structure=cls.structure,
|
|
499
|
+
shell_dialect=cls.shell_dialect,
|
|
500
|
+
binaries=cls.binaries,
|
|
501
|
+
phase_map=cls.phase_map,
|
|
502
|
+
)
|
|
503
|
+
return cls
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Tool name normalization and classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from traceforge.classify.core import Classification, Mechanism
|
|
8
|
+
from traceforge.classify.phases import with_phase_map as _with_phase_map
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from traceforge.classify.config import ClassificationEngine
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_tool_name(
|
|
15
|
+
raw_name: str,
|
|
16
|
+
engine: ClassificationEngine,
|
|
17
|
+
) -> str:
|
|
18
|
+
"""Normalize a raw tool name to its canonical form."""
|
|
19
|
+
if not raw_name:
|
|
20
|
+
return raw_name
|
|
21
|
+
|
|
22
|
+
name = raw_name.strip()
|
|
23
|
+
|
|
24
|
+
if name.startswith("mcp__"):
|
|
25
|
+
parts = name.split("__", 2)
|
|
26
|
+
if len(parts) == 3:
|
|
27
|
+
name = parts[2]
|
|
28
|
+
elif "." in name:
|
|
29
|
+
dot_idx = name.index(".")
|
|
30
|
+
prefix = name[:dot_idx]
|
|
31
|
+
if prefix.replace("_", "").isalpha() and prefix.islower():
|
|
32
|
+
name = name[dot_idx + 1 :]
|
|
33
|
+
|
|
34
|
+
lowered = name.lower().replace("-", "_")
|
|
35
|
+
canonical_map = engine.canonical_tools
|
|
36
|
+
return canonical_map.get(lowered, lowered)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def classify_tool(
|
|
40
|
+
tool_name: str,
|
|
41
|
+
custom_classifications: dict[str, Classification] | None = None,
|
|
42
|
+
*,
|
|
43
|
+
engine: ClassificationEngine,
|
|
44
|
+
) -> Classification:
|
|
45
|
+
"""Classify a tool name into a full Classification object.
|
|
46
|
+
|
|
47
|
+
All returned Classifications carry phase_map — same system as shell commands.
|
|
48
|
+
|
|
49
|
+
Classification priority:
|
|
50
|
+
1. Custom user-provided classifications
|
|
51
|
+
2. MCP server profiles (checked on raw name BEFORE canonical normalization,
|
|
52
|
+
so that MCP-specific suffixes like 'search' don't collide with first-party
|
|
53
|
+
canonical aliases like 'grep')
|
|
54
|
+
3. Built-in canonical tool classifications
|
|
55
|
+
4. MCP verb inference for MCP-formatted names with unknown namespaces
|
|
56
|
+
5. UNKNOWN mechanism fallback
|
|
57
|
+
"""
|
|
58
|
+
from traceforge.classify.mcp import _infer_from_verb, classify_mcp_tool
|
|
59
|
+
|
|
60
|
+
if not tool_name:
|
|
61
|
+
fallback = Classification(mechanism=Mechanism.UNKNOWN, effect=None)
|
|
62
|
+
return _with_phase_map(fallback)
|
|
63
|
+
|
|
64
|
+
canonical = normalize_tool_name(tool_name, engine=engine)
|
|
65
|
+
if custom_classifications:
|
|
66
|
+
lower = canonical.lower()
|
|
67
|
+
for key, cls in custom_classifications.items():
|
|
68
|
+
if key.lower() == lower or normalize_tool_name(key, engine=engine) == canonical:
|
|
69
|
+
if not cls.phase_map:
|
|
70
|
+
return _with_phase_map(cls)
|
|
71
|
+
return cls
|
|
72
|
+
|
|
73
|
+
mcp_result = classify_mcp_tool(tool_name, engine=engine)
|
|
74
|
+
if mcp_result is not None:
|
|
75
|
+
return mcp_result
|
|
76
|
+
|
|
77
|
+
tool_cls_map = engine.tool_classifications
|
|
78
|
+
result = tool_cls_map.get(canonical)
|
|
79
|
+
if result is not None:
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
verb_effect, verb_action = _infer_from_verb(canonical, engine=engine)
|
|
83
|
+
if verb_effect is not None or verb_action is not None:
|
|
84
|
+
cls = Classification(
|
|
85
|
+
mechanism=Mechanism.UNKNOWN,
|
|
86
|
+
effect=verb_effect,
|
|
87
|
+
action=frozenset({verb_action}) if verb_action else frozenset(),
|
|
88
|
+
)
|
|
89
|
+
return _with_phase_map(cls)
|
|
90
|
+
|
|
91
|
+
return _with_phase_map(Classification(mechanism=Mechanism.UNKNOWN, effect=None))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Session workflow dimensions — phase and visibility.
|
|
2
|
+
|
|
3
|
+
These are derived/presentation concerns, not intrinsic semantic properties
|
|
4
|
+
of an action. They live separately from the classification taxonomy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Phase(StrEnum):
|
|
13
|
+
"""Workflow stage inferred from classification dimensions of recent events."""
|
|
14
|
+
|
|
15
|
+
PLANNING = "planning"
|
|
16
|
+
IMPLEMENTATION = "implementation"
|
|
17
|
+
VERIFICATION = "verification"
|
|
18
|
+
EXPLORATION = "exploration"
|
|
19
|
+
REVIEW = "review"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Visibility(StrEnum):
|
|
23
|
+
"""Display audience for an event in a trace view.
|
|
24
|
+
|
|
25
|
+
SYSTEM = bookkeeping, not shown to end users.
|
|
26
|
+
VISIBLE = shown by default.
|
|
27
|
+
COLLAPSED = shown but minimized (e.g. repeated similar events).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
VISIBLE = "visible"
|
|
31
|
+
SYSTEM = "system"
|
|
32
|
+
COLLAPSED = "collapsed"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Traceforge CLI — Click application entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from traceforge.cli.detect import detect
|
|
10
|
+
from traceforge.cli.config_cmd import config
|
|
11
|
+
from traceforge.cli.watch import watch
|
|
12
|
+
from traceforge.cli.score import score
|
|
13
|
+
from traceforge.cli.replay import replay
|
|
14
|
+
from traceforge.cli.status import status
|
|
15
|
+
from traceforge.cli.gate_cmd import gate
|
|
16
|
+
from traceforge.cli.init_cmd import init
|
|
17
|
+
from traceforge.cli.download_cmd import download_model
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group()
|
|
21
|
+
@click.version_option(package_name="traceforge-toolkit")
|
|
22
|
+
def main() -> None:
|
|
23
|
+
"""Traceforge — governance pipeline for AI coding agents."""
|
|
24
|
+
# Force UTF-8 on stdout/stderr so the CLI's Unicode glyphs (box rules,
|
|
25
|
+
# ✓/✗) never raise UnicodeEncodeError on a non-UTF-8 console such as a
|
|
26
|
+
# Windows cp1252 stdout. Guarded because streams may lack reconfigure
|
|
27
|
+
# (e.g. when replaced by an in-memory buffer under test).
|
|
28
|
+
for stream in (sys.stdout, sys.stderr):
|
|
29
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
30
|
+
if reconfigure is not None:
|
|
31
|
+
reconfigure(encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
main.add_command(watch)
|
|
35
|
+
main.add_command(score)
|
|
36
|
+
main.add_command(detect)
|
|
37
|
+
main.add_command(replay)
|
|
38
|
+
main.add_command(config)
|
|
39
|
+
main.add_command(status)
|
|
40
|
+
main.add_command(gate)
|
|
41
|
+
main.add_command(init)
|
|
42
|
+
main.add_command(download_model)
|