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.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. 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)