feat(gsd2): add GSD 2 agent adapter and TypeScript extension#149
feat(gsd2): add GSD 2 agent adapter and TypeScript extension#149hashedone wants to merge 1 commit into
Conversation
Adds first-class GSD 2 (pi/GSD-2) support on top of the PR#95 AgentAdapter abstraction. ### Rust adapter (tracevault-core) - New Gsd2Adapter registered under 'gsd2' and 'gsd-2' - File changes from transcript: write → SHA256 hash + diff, edit → old/new diff, errors skipped - Token usage from agent_end chunks (input/output/cacheRead/cacheWrite) - Model from agent_end and session_start chunks - install_hooks() is a no-op — GSD2 uses in-process extension - 16 new tests covering all code paths ### TypeScript extension (integrations/gsd2-extension/) - Hooks into session_start, tool_execution_end, agent_end, stop - POSTs directly to TraceVault HTTP API (no shell subprocess) - Config from .tracevault/config.toml or ~/.config/tracevault/config.toml - Silent no-op when config is absent - is_error natively available — must_succeed policies work correctly ### Key difference vs CC/Codex GSD2 is an in-process TypeScript extension, not a shell hook. This means: - No transcript file to parse; events arrive via the extension event system - is_error is a first-class bool on every tool result (not inferred from transcript) - Token usage comes from AssistantMessage.usage in agent_end, not transcript chunks
| cfg = loadConfig(ctx.cwd); | ||
| if (!cfg) return; // Not a TraceVault project — silent no-op | ||
|
|
||
| const state = getState(ctx.sessionManager.sessionId ?? crypto.randomUUID()); |
There was a problem hiding this comment.
Fallback session_id is inconsistent: session_start uses crypto.randomUUID() but other handlers use "unknown", causing the same run’s events to be attributed to different sessions.
| const state = getState(ctx.sessionManager.sessionId ?? crypto.randomUUID()); | |
| const state = getState(ctx.sessionManager.sessionId ?? "unknown"); |
Details
✨ AI Reasoning
The code is trying to keep all events in a single per-session stream. However, when the runtime session identifier is absent, the first handler creates state with a random ID, but subsequent handlers use a different fallback string. Those branches cannot refer to the same session state in that scenario, so events are split across different IDs and cleanup targets the wrong map key.
Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
| join(projectRoot, ".tracevault", "config.toml"), | ||
| join(homedir(), ".config", "tracevault", "config.toml"), | ||
| ]; | ||
|
|
||
| for (const path of candidates) { | ||
| if (!existsSync(path)) continue; | ||
| try { | ||
| const raw = readFileSync(path, "utf8"); |
There was a problem hiding this comment.
Potential file inclusion attack via reading file - high severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.
Show fix
| join(projectRoot, ".tracevault", "config.toml"), | |
| join(homedir(), ".config", "tracevault", "config.toml"), | |
| ]; | |
| for (const path of candidates) { | |
| if (!existsSync(path)) continue; | |
| try { | |
| const raw = readFileSync(path, "utf8"); | |
| join(resolve(projectRoot), ".tracevault", "config.toml"), | |
| join(homedir(), ".config", "tracevault", "config.toml"), | |
| ]; | |
| for (const candidatePath of candidates) { | |
| const resolvedBase = resolve(projectRoot); | |
| const resolvedTarget = resolve(candidatePath); | |
| const rel = relative(resolvedBase, resolvedTarget); | |
| if (rel.startsWith('..') || isAbsolute(rel)) { | |
| continue; | |
| } | |
| if (!existsSync(resolvedTarget)) continue; | |
| try { | |
| const raw = readFileSync(resolvedTarget, "utf8"); |
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
| } catch { | ||
| // ignore malformed config |
There was a problem hiding this comment.
Catch block silently ignores malformed config in loadConfig; log or record the parse error (e.g., console.debug or console.error) so malformed config isn't silently swallowed.
Show fix
| } catch { | |
| // ignore malformed config | |
| } catch (err) { | |
| // ignore malformed config and continue searching | |
| console.error(`[tracevault] Failed to parse config at ${path}:`, err); |
Details
✨ AI Reasoning
A try/catch was added around config parsing. The catch is a bare catch (no exception variable or type) and its body contains only a comment that says the error is ignored. This swallows parse errors silently. Silent catches on I/O or parsing operations make debugging harder and can hide configuration issues. The try block reads and parses files (state-changing I/O), so failing to report parse errors reduces observability. Adding minimal error handling (logging or structured handling) would preserve the intended silent-no-op behavior while leaving an audit trail.
Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
| const sessions = new Map<string, SessionState>(); | ||
|
|
||
| function getState(gsdSessionId: string): SessionState { | ||
| let s = sessions.get(gsdSessionId); | ||
| if (!s) { |
There was a problem hiding this comment.
Module-level 'sessions' Map stores per-session state and can persist across requests, risking cross-session data leakage. Consider making session state request-scoped or using an explicit, documented shared cache abstraction.
Show fix
| const sessions = new Map<string, SessionState>(); | |
| function getState(gsdSessionId: string): SessionState { | |
| let s = sessions.get(gsdSessionId); | |
| if (!s) { | |
| const sessions = new Map<string, SessionState>(); | |
| const MAX_CACHE_SIZE = 128; | |
| function getState(gsdSessionId: string): SessionState { | |
| let s = sessions.get(gsdSessionId); | |
| if (!s) { | |
| if (sessions.size >= MAX_CACHE_SIZE) { | |
| sessions.delete(sessions.keys().next().value); | |
| } |
Details
✨ AI Reasoning
The extension now defines a module-level 'sessions' Map that holds SessionState objects keyed by GSD session IDs. This Map is mutated by getState and used across event handlers, making per-session data live in module/global scope. In long-running Node.js processes, module-level mutable state persists across requests and can unintentionally leak or mix data between different sessions or users. The pattern here stores request/session-specific information in a shared global that was introduced by this change, which meets the criteria for unintended global caching and therefore is worth flagging.
Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
Summary
Builds on top of #95 (AgentAdapter abstraction). Adds first-class GSD 2 (pi/GSD-2) support.
Closes #148 (partial — covers GSD2; Codex already covered by #95)
What's included
Rust adapter (
tracevault-core)Gsd2Adapterregistered undergsd2andgsd-2inAgentAdapterRegistrytool_execution_endtranscript chunks (write→ SHA256 + diff,edit→ old/new diff, errors skipped)agent_endchunks using GSD2's{ input, output, cacheRead, cacheWrite }shapeagent_endandsession_startchunksinstall_hooks()is a no-op — GSD2 is in-process, no shell hooks neededTypeScript extension (
integrations/gsd2-extension/)session_start,tool_execution_end,agent_end,stopfetch.tracevault/config.toml(project) or~/.config/tracevault/config.toml(user-wide)Key difference from CC and Codex
GSD 2 is an in-process TypeScript extension, not a shell hook. This means:
is_erroris a first-classboolon everytool_execution_endevent, somust_succeedpolicies (from feat(policies): add must_succeed flag to tool call policies #147) work correctly without any transcript scanningAssistantMessage.usageinagent_end, not transcript JSONL chunksNotes
tool_is_error/tool_use_idfields to add toStreamEventRequest)