solidity-argus 0.3.3 → 0.3.5
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.
- package/package.json +1 -1
- package/src/agents/argus-prompt.ts +67 -8
- package/src/agents/scribe-prompt.ts +13 -5
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/index.ts +0 -0
- package/src/config/schema.ts +7 -2
- package/src/create-hooks.ts +116 -27
- package/src/features/audit-enforcer/audit-enforcer.ts +31 -2
- package/src/features/migration/index.ts +14 -0
- package/src/features/migration/migration-adapter.ts +151 -0
- package/src/features/migration/parity-telemetry.ts +133 -0
- package/src/features/persistent-state/audit-state-manager.ts +28 -6
- package/src/features/persistent-state/event-sink.ts +175 -0
- package/src/features/persistent-state/findings-materializer.ts +51 -0
- package/src/features/persistent-state/index.ts +2 -0
- package/src/features/persistent-state/run-finalizer.ts +192 -0
- package/src/features/persistent-state/run-journal.ts +15 -4
- package/src/hooks/agent-tracker.ts +15 -0
- package/src/hooks/event-hook.ts +93 -1
- package/src/hooks/system-prompt-hook.ts +20 -0
- package/src/hooks/tool-tracking-hook.ts +263 -33
- package/src/shared/audit-artifact-resolver.ts +75 -0
- package/src/shared/drop-diagnostics.ts +108 -0
- package/src/shared/file-utils.ts +7 -2
- package/src/shared/index.ts +14 -0
- package/src/shared/path-root-resolver.ts +34 -0
- package/src/shared/report-path-resolver.ts +70 -0
- package/src/solodit-lifecycle.ts +86 -7
- package/src/state/adapters.ts +262 -0
- package/src/state/index.ts +15 -0
- package/src/state/projectors.ts +437 -0
- package/src/state/schemas.ts +453 -0
- package/src/state/types.ts +6 -0
- package/src/tools/report-generator-tool.ts +647 -36
- package/src/tools/report-preflight.ts +79 -0
- package/src/tools/solodit-search-tool.ts +15 -24
- package/src/utils/solodit-health.ts +18 -0
|
@@ -11,6 +11,7 @@ export type AgentTracker = ReturnType<typeof createAgentTracker>
|
|
|
11
11
|
|
|
12
12
|
export function createAgentTracker() {
|
|
13
13
|
const sessions = new Map<string, string>()
|
|
14
|
+
const childSessions = new Map<string, Set<string>>()
|
|
14
15
|
|
|
15
16
|
const trackSession = (sessionID: string, agent?: string): void => {
|
|
16
17
|
if (!agent) {
|
|
@@ -49,5 +50,19 @@ export function createAgentTracker() {
|
|
|
49
50
|
getTrackedSessions: (): Map<string, string> => {
|
|
50
51
|
return sessions
|
|
51
52
|
},
|
|
53
|
+
|
|
54
|
+
trackChildSession: (parentSessionId: string, childSessionId: string): void => {
|
|
55
|
+
let children = childSessions.get(parentSessionId)
|
|
56
|
+
if (!children) {
|
|
57
|
+
children = new Set()
|
|
58
|
+
childSessions.set(parentSessionId, children)
|
|
59
|
+
}
|
|
60
|
+
children.add(childSessionId)
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
getChildSessions: (parentSessionId: string): string[] => {
|
|
64
|
+
const children = childSessions.get(parentSessionId)
|
|
65
|
+
return children ? Array.from(children) : []
|
|
66
|
+
},
|
|
52
67
|
}
|
|
53
68
|
}
|
package/src/hooks/event-hook.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import type { EventSink } from "../features/persistent-state/event-sink"
|
|
2
|
+
import type { FinalizationResult } from "../features/persistent-state/run-finalizer"
|
|
3
|
+
import { finalizeRun } from "../features/persistent-state/run-finalizer"
|
|
1
4
|
import { createLogger } from "../shared/logger"
|
|
2
5
|
import { createAuditState } from "../state/audit-state"
|
|
6
|
+
import type { AuditEvent } from "../state/schemas"
|
|
7
|
+
import { SCHEMA_VERSION } from "../state/schemas"
|
|
3
8
|
import type { AuditState } from "../state/types"
|
|
4
9
|
|
|
5
10
|
export type AuditEventType =
|
|
@@ -29,17 +34,50 @@ export function createEventHook(
|
|
|
29
34
|
hook: EventHookFn
|
|
30
35
|
getAuditState: () => AuditState | null
|
|
31
36
|
setAuditState: (state: AuditState | null) => void
|
|
37
|
+
setEventSink: (sink: EventSink | null) => void
|
|
38
|
+
getLastFinalizationResult: () => FinalizationResult | null
|
|
32
39
|
} {
|
|
33
40
|
const logger = createLogger()
|
|
34
41
|
let currentAuditState: AuditState | null = null
|
|
42
|
+
let eventSink: EventSink | null = null
|
|
43
|
+
let lastFinalizationResult: FinalizationResult | null = null
|
|
35
44
|
|
|
36
45
|
const getAuditState = (): AuditState | null => currentAuditState
|
|
37
46
|
const setAuditState = (state: AuditState | null): void => {
|
|
38
47
|
currentAuditState = state
|
|
39
48
|
}
|
|
49
|
+
const setEventSink = (sink: EventSink | null): void => {
|
|
50
|
+
eventSink = sink
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function emitToSink(
|
|
54
|
+
type: AuditEvent["type"],
|
|
55
|
+
runId: string,
|
|
56
|
+
sessionId: string | undefined,
|
|
57
|
+
payload: unknown,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
if (!eventSink) return
|
|
60
|
+
try {
|
|
61
|
+
await eventSink.append({
|
|
62
|
+
type,
|
|
63
|
+
run_id: runId,
|
|
64
|
+
seq: 0, // auto-assigned by sink
|
|
65
|
+
session_id: sessionId ?? "",
|
|
66
|
+
source: "event-hook",
|
|
67
|
+
schema_version: SCHEMA_VERSION,
|
|
68
|
+
timestamp: Date.now(),
|
|
69
|
+
payload,
|
|
70
|
+
})
|
|
71
|
+
} catch (error) {
|
|
72
|
+
logger.error(
|
|
73
|
+
`Failed to emit ${type} event to sink: ${error instanceof Error ? error.message : String(error)}`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
40
77
|
|
|
41
78
|
const hook: EventHookFn = async (input): Promise<void> => {
|
|
42
79
|
const { type, sessionId } = input.event
|
|
80
|
+
let preDeleteState: AuditState | null = null
|
|
43
81
|
|
|
44
82
|
switch (type) {
|
|
45
83
|
case "session.created": {
|
|
@@ -73,6 +111,7 @@ export function createEventHook(
|
|
|
73
111
|
}
|
|
74
112
|
|
|
75
113
|
case "session.deleted": {
|
|
114
|
+
preDeleteState = currentAuditState
|
|
76
115
|
currentAuditState = null
|
|
77
116
|
break
|
|
78
117
|
}
|
|
@@ -93,7 +132,60 @@ export function createEventHook(
|
|
|
93
132
|
logger.error(`Sub-handler failed for event ${type}:`, error)
|
|
94
133
|
}
|
|
95
134
|
}
|
|
135
|
+
|
|
136
|
+
// Emit canonical events to sink (after sub-handlers, so sink may have been set during session.created)
|
|
137
|
+
switch (type) {
|
|
138
|
+
case "session.created": {
|
|
139
|
+
if (currentAuditState) {
|
|
140
|
+
await emitToSink("session.created", currentAuditState.sessionId, sessionId, {
|
|
141
|
+
projectDir: currentAuditState.projectDir,
|
|
142
|
+
sessionId: currentAuditState.sessionId,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
break
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case "session.idle": {
|
|
149
|
+
if (currentAuditState) {
|
|
150
|
+
await emitToSink("session.idle", currentAuditState.sessionId, sessionId, {
|
|
151
|
+
findingsCount: currentAuditState.findings.length,
|
|
152
|
+
toolsExecutedCount: currentAuditState.toolsExecuted.length,
|
|
153
|
+
phase: currentAuditState.currentPhase,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case "session.deleted": {
|
|
160
|
+
if (preDeleteState) {
|
|
161
|
+
await emitToSink("session.deleted", preDeleteState.sessionId, sessionId, {
|
|
162
|
+
archived: true,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
if (eventSink) {
|
|
166
|
+
try {
|
|
167
|
+
lastFinalizationResult = await finalizeRun(
|
|
168
|
+
preDeleteState.sessionId,
|
|
169
|
+
preDeleteState.projectDir,
|
|
170
|
+
eventSink,
|
|
171
|
+
)
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.error(
|
|
174
|
+
`Failed to finalize run ${preDeleteState.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
eventSink = null
|
|
180
|
+
break
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
default:
|
|
184
|
+
break
|
|
185
|
+
}
|
|
96
186
|
}
|
|
97
187
|
|
|
98
|
-
|
|
188
|
+
const getLastFinalizationResult = (): FinalizationResult | null => lastFinalizationResult
|
|
189
|
+
|
|
190
|
+
return { hook, getAuditState, setAuditState, setEventSink, getLastFinalizationResult }
|
|
99
191
|
}
|
|
@@ -12,6 +12,14 @@ const TOOL_SHORT_NAMES: Record<string, string> = {
|
|
|
12
12
|
}
|
|
13
13
|
const KEY_TOOLS = ["slither", "forge-test", "patterns", "solodit", "analyzer"]
|
|
14
14
|
|
|
15
|
+
|
|
16
|
+
/** Maps unavailable-tool short names to their KEY_TOOLS counterpart */
|
|
17
|
+
const UNAVAILABLE_TO_KEY_TOOL: Record<string, string> = {
|
|
18
|
+
slither: "slither",
|
|
19
|
+
forge: "forge-test",
|
|
20
|
+
solodit: "solodit",
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
export interface SystemPromptHookDeps {
|
|
16
24
|
getAuditState: () => AuditState | null
|
|
17
25
|
getAgentForSession: (sessionID: string) => string | undefined
|
|
@@ -69,8 +77,19 @@ export function buildDynamicContext(
|
|
|
69
77
|
(t) => `${t}=${executedToolNames.has(t) ? "done" : "pending"}`,
|
|
70
78
|
).join(" ")
|
|
71
79
|
const unavailable = auditState.unavailableTools ?? []
|
|
80
|
+
const excusedTools = new Set(
|
|
81
|
+
unavailable.map((t) => UNAVAILABLE_TO_KEY_TOOL[t]).filter(Boolean),
|
|
82
|
+
)
|
|
83
|
+
const pendingKeyTools = KEY_TOOLS.filter(
|
|
84
|
+
(t) => !executedToolNames.has(t) && !excusedTools.has(t),
|
|
85
|
+
)
|
|
86
|
+
const gateStatus =
|
|
87
|
+
pendingKeyTools.length > 0
|
|
88
|
+
? `REPORTING GATE: BLOCKED \u2014 key tools pending: ${pendingKeyTools.join(", ")}`
|
|
89
|
+
: "REPORTING GATE: ALLOWED"
|
|
72
90
|
const lines: string[] = [
|
|
73
91
|
`<argus-context agent="${agent}">`,
|
|
92
|
+
gateStatus,
|
|
74
93
|
`Phase: ${auditState.currentPhase}`,
|
|
75
94
|
`Contracts: ${auditState.contractsReviewed.length} reviewed`,
|
|
76
95
|
`Findings: Critical=${severityCounts.Critical} High=${severityCounts.High} Medium=${severityCounts.Medium} Low=${severityCounts.Low} Info=${severityCounts.Informational}`,
|
|
@@ -91,6 +110,7 @@ export function buildDynamicContext(
|
|
|
91
110
|
const doneCount = KEY_TOOLS.filter((t) => executedToolNames.has(t)).length
|
|
92
111
|
summary = [
|
|
93
112
|
`<argus-context agent="${agent}">`,
|
|
113
|
+
gateStatus,
|
|
94
114
|
`Phase: ${auditState.currentPhase} | Findings: ${auditState.findings.length} | Contracts: ${auditState.contractsReviewed.length} | Tasks: ${doneCount}/${KEY_TOOLS.length} done`,
|
|
95
115
|
"</argus-context>",
|
|
96
116
|
].join("\n")
|
|
@@ -1,7 +1,21 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import type { EventSink } from "../features/persistent-state/event-sink"
|
|
3
|
+
import type {
|
|
4
|
+
DropDiagnostic,
|
|
5
|
+
DropDiagnosticsCollector,
|
|
6
|
+
DropPolicy,
|
|
7
|
+
} from "../shared/drop-diagnostics"
|
|
8
|
+
import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
|
|
9
|
+
import { createLogger } from "../shared/logger"
|
|
10
|
+
import { normalizeToCanonicalFinding } from "../state/adapters"
|
|
1
11
|
import type { FindingStore } from "../state/finding-store"
|
|
2
12
|
import { createFindingStore } from "../state/finding-store"
|
|
13
|
+
import type { AuditEvent } from "../state/schemas"
|
|
14
|
+
import { SCHEMA_VERSION } from "../state/schemas"
|
|
3
15
|
import type { AuditState, FindingSeverity, FuzzCounterexample, SoloditResult } from "../state/types"
|
|
4
16
|
|
|
17
|
+
const logger = createLogger()
|
|
18
|
+
|
|
5
19
|
type ToolHookInput = {
|
|
6
20
|
tool: string
|
|
7
21
|
args: unknown
|
|
@@ -13,6 +27,13 @@ type ToolExecutionMetadata = {
|
|
|
13
27
|
findingsCount: number
|
|
14
28
|
}
|
|
15
29
|
|
|
30
|
+
export type ToolTrackingOptions = {
|
|
31
|
+
getEventSink?: () => EventSink | null
|
|
32
|
+
getSessionId?: () => string
|
|
33
|
+
dropPolicy?: DropPolicy
|
|
34
|
+
onChildSessionDetected?: (parentSessionId: string, childSessionId: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
const VALID_SEVERITIES: ReadonlySet<string> = new Set([
|
|
17
38
|
"Critical",
|
|
18
39
|
"High",
|
|
@@ -56,7 +77,90 @@ function toRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
56
77
|
return undefined
|
|
57
78
|
}
|
|
58
79
|
|
|
59
|
-
function
|
|
80
|
+
async function emitToSink(sink: EventSink, event: AuditEvent): Promise<void> {
|
|
81
|
+
try {
|
|
82
|
+
await sink.append(event)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
logger.error(
|
|
85
|
+
`Failed to emit ${event.type} event to sink: ${error instanceof Error ? error.message : String(error)}`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildEvent(
|
|
91
|
+
type: AuditEvent["type"],
|
|
92
|
+
runId: string,
|
|
93
|
+
sessionId: string,
|
|
94
|
+
toolCallId: string,
|
|
95
|
+
payload: unknown,
|
|
96
|
+
): AuditEvent {
|
|
97
|
+
return {
|
|
98
|
+
type,
|
|
99
|
+
run_id: runId,
|
|
100
|
+
seq: 0,
|
|
101
|
+
session_id: sessionId,
|
|
102
|
+
tool_call_id: toolCallId,
|
|
103
|
+
source: "tool-tracking-hook",
|
|
104
|
+
schema_version: SCHEMA_VERSION,
|
|
105
|
+
timestamp: Date.now(),
|
|
106
|
+
payload,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Defensively parse a child session_id from a `task` tool result.
|
|
112
|
+
* The result may be JSON with a top-level or nested `session_id` field,
|
|
113
|
+
* or plain text with an embedded JSON fragment.
|
|
114
|
+
*/
|
|
115
|
+
function parseChildSessionId(result: string): string | null {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(result)
|
|
118
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
119
|
+
if (typeof parsed.session_id === "string" && parsed.session_id.length > 0) {
|
|
120
|
+
return parsed.session_id
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
typeof parsed.result === "object" &&
|
|
124
|
+
parsed.result !== null &&
|
|
125
|
+
!Array.isArray(parsed.result) &&
|
|
126
|
+
typeof parsed.result.session_id === "string" &&
|
|
127
|
+
parsed.result.session_id.length > 0
|
|
128
|
+
) {
|
|
129
|
+
return parsed.result.session_id
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
const match = result.match(/"session_id"\s*:\s*"([^"]+)"/)
|
|
134
|
+
if (match?.[1]) {
|
|
135
|
+
return match[1]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function identifyMissingFields(
|
|
142
|
+
finding: Record<string, unknown>,
|
|
143
|
+
requiredFields: readonly string[],
|
|
144
|
+
): string[] {
|
|
145
|
+
const missing: string[] = []
|
|
146
|
+
for (const field of requiredFields) {
|
|
147
|
+
if (field === "lines") {
|
|
148
|
+
if (!toLines(finding.lines)) missing.push(field)
|
|
149
|
+
} else if (typeof finding[field] !== "string") {
|
|
150
|
+
missing.push(field)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return missing
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SLITHER_REQUIRED = ["check", "description", "file", "lines"] as const
|
|
157
|
+
const PATTERN_REQUIRED = ["pattern", "description", "file", "lines"] as const
|
|
158
|
+
|
|
159
|
+
function processSlitherResult(
|
|
160
|
+
parsed: Record<string, unknown>,
|
|
161
|
+
store: FindingStore,
|
|
162
|
+
diag: DropDiagnosticsCollector,
|
|
163
|
+
): number {
|
|
60
164
|
const findings = parsed.findings
|
|
61
165
|
if (!Array.isArray(findings)) return 0
|
|
62
166
|
|
|
@@ -76,6 +180,12 @@ function processSlitherResult(parsed: Record<string, unknown>, store: FindingSto
|
|
|
76
180
|
typeof file !== "string" ||
|
|
77
181
|
!lines
|
|
78
182
|
) {
|
|
183
|
+
const missing = identifyMissingFields(finding, SLITHER_REQUIRED)
|
|
184
|
+
diag.error(
|
|
185
|
+
"MISSING_REQUIRED_FIELD",
|
|
186
|
+
`Slither finding skipped: missing ${missing.join(", ")}`,
|
|
187
|
+
missing[0],
|
|
188
|
+
)
|
|
79
189
|
continue
|
|
80
190
|
}
|
|
81
191
|
|
|
@@ -94,7 +204,11 @@ function processSlitherResult(parsed: Record<string, unknown>, store: FindingSto
|
|
|
94
204
|
return count
|
|
95
205
|
}
|
|
96
206
|
|
|
97
|
-
function processPatternResult(
|
|
207
|
+
function processPatternResult(
|
|
208
|
+
parsed: Record<string, unknown>,
|
|
209
|
+
store: FindingStore,
|
|
210
|
+
diag: DropDiagnosticsCollector,
|
|
211
|
+
): number {
|
|
98
212
|
const sources = parsed.sources
|
|
99
213
|
if (!Array.isArray(sources)) return 0
|
|
100
214
|
|
|
@@ -121,6 +235,12 @@ function processPatternResult(parsed: Record<string, unknown>, store: FindingSto
|
|
|
121
235
|
typeof file !== "string" ||
|
|
122
236
|
!lines
|
|
123
237
|
) {
|
|
238
|
+
const missing = identifyMissingFields(match, PATTERN_REQUIRED)
|
|
239
|
+
diag.error(
|
|
240
|
+
"MISSING_REQUIRED_FIELD",
|
|
241
|
+
`Pattern finding skipped: missing ${missing.join(", ")}`,
|
|
242
|
+
missing[0],
|
|
243
|
+
)
|
|
124
244
|
continue
|
|
125
245
|
}
|
|
126
246
|
|
|
@@ -141,7 +261,6 @@ function processPatternResult(parsed: Record<string, unknown>, store: FindingSto
|
|
|
141
261
|
}
|
|
142
262
|
|
|
143
263
|
function processContractAnalyzerResult(parsed: Record<string, unknown>, state: AuditState): void {
|
|
144
|
-
// Handle direct ContractProfile format (actual tool output)
|
|
145
264
|
if (typeof parsed.filePath === "string") {
|
|
146
265
|
if (!state.contractsReviewed.includes(parsed.filePath)) {
|
|
147
266
|
state.contractsReviewed.push(parsed.filePath)
|
|
@@ -149,7 +268,6 @@ function processContractAnalyzerResult(parsed: Record<string, unknown>, state: A
|
|
|
149
268
|
return
|
|
150
269
|
}
|
|
151
270
|
|
|
152
|
-
// Handle wrapped { contractProfile: { filePath } } format
|
|
153
271
|
const profile = toRecord(parsed.contractProfile)
|
|
154
272
|
if (profile && typeof profile.filePath === "string") {
|
|
155
273
|
if (!state.contractsReviewed.includes(profile.filePath)) {
|
|
@@ -220,19 +338,6 @@ function processSoloditResult(parsed: Record<string, unknown>, state: AuditState
|
|
|
220
338
|
})
|
|
221
339
|
}
|
|
222
340
|
|
|
223
|
-
/**
|
|
224
|
-
* Records a tool execution in the audit state.
|
|
225
|
-
*
|
|
226
|
-
* Multiple entries per tool name are allowed — if the same tool runs multiple times
|
|
227
|
-
* (e.g., argus_slither_analyze on different targets), each execution is recorded
|
|
228
|
-
* with its own findingsCount.
|
|
229
|
-
*
|
|
230
|
-
* Timing limitation: startTime and endTime are both set to Date.now() because this
|
|
231
|
-
* hook fires in the tool.execute.after phase, after execution has already completed.
|
|
232
|
-
* We cannot capture the actual start time. This is a known limitation of the hook
|
|
233
|
-
* architecture. For accurate timing, the hook would need to fire in tool.execute.before
|
|
234
|
-
* and tool.execute.after phases separately.
|
|
235
|
-
*/
|
|
236
341
|
function recordToolExecution(state: AuditState, toolName: string, findingsCount: number): void {
|
|
237
342
|
const now = Date.now()
|
|
238
343
|
state.toolsExecuted.push({
|
|
@@ -244,18 +349,18 @@ function recordToolExecution(state: AuditState, toolName: string, findingsCount:
|
|
|
244
349
|
})
|
|
245
350
|
}
|
|
246
351
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
* Findings are deduplicated via the FindingStore (by check+file+lines).
|
|
253
|
-
*/
|
|
352
|
+
export type ToolTrackingHook = {
|
|
353
|
+
(input: ToolHookInput): Promise<void>
|
|
354
|
+
getLastDiagnostics(): DropDiagnostic[]
|
|
355
|
+
}
|
|
356
|
+
|
|
254
357
|
export function createToolTrackingHook(
|
|
255
358
|
getAuditState: () => AuditState | null,
|
|
256
359
|
onStateChanged?: (metadata: ToolExecutionMetadata) => void,
|
|
257
|
-
|
|
360
|
+
options?: ToolTrackingOptions,
|
|
361
|
+
): ToolTrackingHook {
|
|
258
362
|
const storesByState = new WeakMap<AuditState, FindingStore>()
|
|
363
|
+
let lastDiagnostics: DropDiagnostic[] = []
|
|
259
364
|
|
|
260
365
|
function resolveStateAndStore(): { state: AuditState; store: FindingStore } | null {
|
|
261
366
|
const state = getAuditState()
|
|
@@ -270,19 +375,101 @@ export function createToolTrackingHook(
|
|
|
270
375
|
return { state, store }
|
|
271
376
|
}
|
|
272
377
|
|
|
273
|
-
|
|
378
|
+
const hookFn = async (input: ToolHookInput): Promise<void> => {
|
|
379
|
+
// Handle task tool (subagent dispatch) before argus_ filter
|
|
380
|
+
if (input.tool === "task") {
|
|
381
|
+
const childSessionId = parseChildSessionId(input.result)
|
|
382
|
+
const correlationId = randomUUID()
|
|
383
|
+
const resolved = resolveStateAndStore()
|
|
384
|
+
const sink = options?.getEventSink?.()
|
|
385
|
+
const sessionId = options?.getSessionId?.() ?? ""
|
|
386
|
+
const toolCallId = randomUUID()
|
|
387
|
+
|
|
388
|
+
if (childSessionId) {
|
|
389
|
+
options?.onChildSessionDetected?.(sessionId, childSessionId)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (sink && resolved) {
|
|
393
|
+
const runId = resolved.state.sessionId
|
|
394
|
+
await emitToSink(
|
|
395
|
+
sink,
|
|
396
|
+
buildEvent("tool.started", runId, sessionId, toolCallId, {
|
|
397
|
+
tool: "task",
|
|
398
|
+
args: input.args,
|
|
399
|
+
correlation_id: correlationId,
|
|
400
|
+
child_session_id: childSessionId ?? null,
|
|
401
|
+
}),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
await emitToSink(
|
|
405
|
+
sink,
|
|
406
|
+
buildEvent("tool.completed", runId, sessionId, toolCallId, {
|
|
407
|
+
tool: "task",
|
|
408
|
+
findingsCount: 0,
|
|
409
|
+
success: true,
|
|
410
|
+
correlation_id: correlationId,
|
|
411
|
+
child_session_id: childSessionId ?? null,
|
|
412
|
+
}),
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (resolved) {
|
|
417
|
+
recordToolExecution(resolved.state, "task", 0)
|
|
418
|
+
onStateChanged?.({ tool: "task", findingsCount: 0 })
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
274
424
|
if (!input.tool.startsWith("argus_")) {
|
|
275
425
|
return
|
|
276
426
|
}
|
|
277
427
|
|
|
278
428
|
const resolved = resolveStateAndStore()
|
|
279
|
-
if (!resolved)
|
|
429
|
+
if (!resolved) {
|
|
430
|
+
const sinkForNoState = options?.getEventSink?.()
|
|
431
|
+
if (sinkForNoState) {
|
|
432
|
+
const toolCallId = randomUUID()
|
|
433
|
+
await emitToSink(
|
|
434
|
+
sinkForNoState,
|
|
435
|
+
buildEvent("tool.started", "", "", toolCallId, {
|
|
436
|
+
tool: input.tool,
|
|
437
|
+
args: input.args,
|
|
438
|
+
}),
|
|
439
|
+
)
|
|
440
|
+
await emitToSink(
|
|
441
|
+
sinkForNoState,
|
|
442
|
+
buildEvent("tool.completed", "", "", toolCallId, {
|
|
443
|
+
tool: input.tool,
|
|
444
|
+
findingsCount: 0,
|
|
445
|
+
success: false,
|
|
446
|
+
}),
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
return
|
|
450
|
+
}
|
|
280
451
|
|
|
281
452
|
const { state: auditState, store } = resolved
|
|
453
|
+
const sink = options?.getEventSink?.()
|
|
454
|
+
const runId = auditState.sessionId
|
|
455
|
+
const sessionId = options?.getSessionId?.() ?? ""
|
|
456
|
+
const toolCallId = randomUUID()
|
|
457
|
+
const policy = options?.dropPolicy ?? "warn"
|
|
458
|
+
const diag = createDropDiagnosticsCollector(policy, "tool-tracking-hook", input.tool)
|
|
459
|
+
|
|
460
|
+
if (sink) {
|
|
461
|
+
await emitToSink(
|
|
462
|
+
sink,
|
|
463
|
+
buildEvent("tool.started", runId, sessionId, toolCallId, {
|
|
464
|
+
tool: input.tool,
|
|
465
|
+
args: input.args,
|
|
466
|
+
}),
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const findingsCountBefore = auditState.findings.length
|
|
282
471
|
|
|
283
|
-
// Handle argus_skill_load first — it returns markdown, not JSON
|
|
284
472
|
if (input.tool === "argus_skill_load") {
|
|
285
|
-
// Extract skill name from markdown header: "## Argus Skill: {name} [Source: ...]"
|
|
286
473
|
const nameMatch = input.result.match(/^##\s+Argus Skill:\s+(.+?)(?:\s+\[|$)/m)
|
|
287
474
|
const skillName = nameMatch?.[1]?.trim()
|
|
288
475
|
if (skillName) {
|
|
@@ -293,6 +480,19 @@ export function createToolTrackingHook(
|
|
|
293
480
|
}
|
|
294
481
|
recordToolExecution(auditState, input.tool, 0)
|
|
295
482
|
onStateChanged?.({ tool: input.tool, findingsCount: 0 })
|
|
483
|
+
|
|
484
|
+
if (sink) {
|
|
485
|
+
await emitToSink(
|
|
486
|
+
sink,
|
|
487
|
+
buildEvent("tool.completed", runId, sessionId, toolCallId, {
|
|
488
|
+
tool: input.tool,
|
|
489
|
+
findingsCount: 0,
|
|
490
|
+
success: true,
|
|
491
|
+
}),
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
lastDiagnostics = diag.getDiagnostics()
|
|
296
496
|
return
|
|
297
497
|
}
|
|
298
498
|
|
|
@@ -300,20 +500,26 @@ export function createToolTrackingHook(
|
|
|
300
500
|
try {
|
|
301
501
|
parsed = JSON.parse(input.result)
|
|
302
502
|
} catch {
|
|
303
|
-
|
|
503
|
+
diag.error("MALFORMED_JSON", `Failed to parse JSON result from ${input.tool}`)
|
|
504
|
+
lastDiagnostics = diag.getDiagnostics()
|
|
505
|
+
diag.throwIfStrict()
|
|
506
|
+
return
|
|
304
507
|
}
|
|
305
508
|
|
|
306
509
|
const record = toRecord(parsed)
|
|
307
|
-
if (!record)
|
|
510
|
+
if (!record) {
|
|
511
|
+
lastDiagnostics = diag.getDiagnostics()
|
|
512
|
+
return
|
|
513
|
+
}
|
|
308
514
|
|
|
309
515
|
let findingsCount = 0
|
|
310
516
|
|
|
311
517
|
switch (input.tool) {
|
|
312
518
|
case "argus_slither_analyze":
|
|
313
|
-
findingsCount = processSlitherResult(record, store)
|
|
519
|
+
findingsCount = processSlitherResult(record, store, diag)
|
|
314
520
|
break
|
|
315
521
|
case "argus_check_patterns":
|
|
316
|
-
findingsCount = processPatternResult(record, store)
|
|
522
|
+
findingsCount = processPatternResult(record, store, diag)
|
|
317
523
|
break
|
|
318
524
|
case "argus_analyze_contract":
|
|
319
525
|
processContractAnalyzerResult(record, auditState)
|
|
@@ -386,7 +592,31 @@ export function createToolTrackingHook(
|
|
|
386
592
|
}
|
|
387
593
|
}
|
|
388
594
|
|
|
595
|
+
lastDiagnostics = diag.getDiagnostics()
|
|
596
|
+
diag.throwIfStrict()
|
|
597
|
+
|
|
389
598
|
recordToolExecution(auditState, input.tool, findingsCount)
|
|
390
599
|
onStateChanged?.({ tool: input.tool, findingsCount })
|
|
600
|
+
|
|
601
|
+
if (sink) {
|
|
602
|
+
const newFindings = auditState.findings.slice(findingsCountBefore)
|
|
603
|
+
for (const finding of newFindings) {
|
|
604
|
+
const { data: canonical } = normalizeToCanonicalFinding(finding, runId, 0)
|
|
605
|
+
await emitToSink(sink, buildEvent("finding.added", runId, sessionId, toolCallId, canonical))
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
await emitToSink(
|
|
609
|
+
sink,
|
|
610
|
+
buildEvent("tool.completed", runId, sessionId, toolCallId, {
|
|
611
|
+
tool: input.tool,
|
|
612
|
+
findingsCount,
|
|
613
|
+
success: true,
|
|
614
|
+
}),
|
|
615
|
+
)
|
|
616
|
+
}
|
|
391
617
|
}
|
|
618
|
+
|
|
619
|
+
hookFn.getLastDiagnostics = (): DropDiagnostic[] => lastDiagnostics
|
|
620
|
+
|
|
621
|
+
return hookFn
|
|
392
622
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { defaultRootResolver } from "./path-root-resolver"
|
|
3
|
+
|
|
4
|
+
export class ArtifactResolverError extends Error {
|
|
5
|
+
constructor(message: string) {
|
|
6
|
+
super(message)
|
|
7
|
+
this.name = "ArtifactResolverError"
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AuditArtifactPaths {
|
|
12
|
+
/** {projectDir}/.argus/argus-state.json */
|
|
13
|
+
stateFile: string
|
|
14
|
+
/** {projectDir}/.argus/runs/{runId}/events.jsonl */
|
|
15
|
+
journalFile: string
|
|
16
|
+
/** {projectDir}/.argus/runs/{runId}/findings.json */
|
|
17
|
+
findingsFile: string
|
|
18
|
+
/** {projectDir}/.argus/reports */
|
|
19
|
+
reportDir: string
|
|
20
|
+
/** {projectDir}/.argus/runs/{runId}/evidence */
|
|
21
|
+
evidenceDir: string
|
|
22
|
+
/** {projectDir}/.argus/archives */
|
|
23
|
+
archiveDir: string
|
|
24
|
+
/** {projectDir}/.argus/runs/{runId} */
|
|
25
|
+
runDir: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AuditArtifactResolver {
|
|
29
|
+
readonly runId: string
|
|
30
|
+
readonly projectDir: string
|
|
31
|
+
paths(): AuditArtifactPaths
|
|
32
|
+
/** Returns {reportDir}/{filename} */
|
|
33
|
+
reportFilePath(filename: string): string
|
|
34
|
+
/** Returns {evidenceDir}/{filename} */
|
|
35
|
+
evidenceFilePath(filename: string): string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createAuditArtifactResolver(
|
|
39
|
+
runId: string,
|
|
40
|
+
projectDir: string,
|
|
41
|
+
): AuditArtifactResolver {
|
|
42
|
+
if (!runId || runId.trim() === "") {
|
|
43
|
+
throw new ArtifactResolverError("runId must not be empty")
|
|
44
|
+
}
|
|
45
|
+
if (!projectDir || projectDir.trim() === "") {
|
|
46
|
+
throw new ArtifactResolverError("projectDir must not be empty")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const writeRoot = defaultRootResolver.writeRoot(projectDir)
|
|
50
|
+
const runDir = join(writeRoot, "runs", runId)
|
|
51
|
+
|
|
52
|
+
const cachedPaths: AuditArtifactPaths = {
|
|
53
|
+
stateFile: join(writeRoot, "argus-state.json"),
|
|
54
|
+
journalFile: join(runDir, "events.jsonl"),
|
|
55
|
+
findingsFile: join(runDir, "findings.json"),
|
|
56
|
+
reportDir: join(writeRoot, "reports"),
|
|
57
|
+
evidenceDir: join(runDir, "evidence"),
|
|
58
|
+
archiveDir: join(writeRoot, "archives"),
|
|
59
|
+
runDir,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
runId,
|
|
64
|
+
projectDir,
|
|
65
|
+
paths(): AuditArtifactPaths {
|
|
66
|
+
return cachedPaths
|
|
67
|
+
},
|
|
68
|
+
reportFilePath(filename: string): string {
|
|
69
|
+
return join(cachedPaths.reportDir, filename)
|
|
70
|
+
},
|
|
71
|
+
evidenceFilePath(filename: string): string {
|
|
72
|
+
return join(cachedPaths.evidenceDir, filename)
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|