granttap-mcp 0.8.17 → 0.8.18
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/README.md +28 -4
- package/apps/bridge/src/bin/claude-hook.ts +3 -0
- package/apps/bridge/src/bin/codex-policy-hook.ts +3 -0
- package/apps/bridge/src/capabilities/skills.ts +25 -6
- package/apps/bridge/src/engine/README.md +11 -1
- package/apps/bridge/src/engine/engine-client.ts +2 -0
- package/apps/bridge/src/engine/engine-invocation-protocol.ts +117 -0
- package/apps/bridge/src/engine/engine-protocol.ts +13 -3
- package/apps/bridge/src/engine/invocation-decision-parse.ts +35 -0
- package/apps/bridge/src/engine/invocation-ingest.ts +167 -0
- package/apps/bridge/src/engine/invocation-log-reader.ts +71 -0
- package/apps/bridge/src/engine/invocation-parse.ts +120 -0
- package/apps/bridge/src/engine/invocation-query.ts +53 -0
- package/apps/bridge/src/engine/invocation-scope.ts +39 -0
- package/apps/bridge/src/mesh/catalog.ts +7 -1
- package/apps/bridge/src/mesh/map.ts +7 -2
- package/apps/bridge/src/mesh/observed-claims.ts +8 -3
- package/apps/bridge/src/mesh/runtime-handoff.ts +3 -1
- package/apps/bridge/src/mesh/runtime.ts +6 -2
- package/apps/bridge/src/mesh/scoped-view.ts +7 -6
- package/apps/bridge/src/mesh/store-support.ts +25 -5
- package/apps/bridge/src/mesh/store.ts +9 -6
- package/apps/bridge/src/monitor.ts +7 -0
- package/apps/bridge/src/policy/capability-fingerprint.ts +24 -4
- package/apps/bridge/src/policy/decision-log.ts +23 -1
- package/apps/bridge/src/policy/effective-action.ts +8 -5
- package/apps/bridge/src/session-keys.ts +2 -1
- package/apps/bridge/src/sessions/claude.ts +5 -0
- package/apps/bridge/src/sessions/codex.ts +5 -0
- package/apps/mcp/src/mcp-tools/mesh-resource.ts +5 -2
- package/cursor-plugin/README.md +7 -9
- package/docs/images/iphone-task-route.png +0 -0
- package/package.json +1 -1
- package/packages/protocol/messages/mesh-invocations.ts +81 -0
- package/packages/protocol/messages/mesh.ts +3 -0
- package/packages/protocol/schema.ts +4 -0
package/README.md
CHANGED
|
@@ -54,15 +54,38 @@ and completion events through the existing `notify` tool, and read state from a
|
|
|
54
54
|
Mesh resource. Full transcripts and hidden reasoning are never mesh payloads.
|
|
55
55
|
Claims have TTLs; a colliding claim is rejected before it is recorded so agents
|
|
56
56
|
can choose different work or contact the owner before escalating to Needs You.
|
|
57
|
+
Observed edit-tool requests are intent claims, not confirmed writes. Resource
|
|
58
|
+
identity includes the repository; equal relative paths from separate repositories
|
|
59
|
+
do not conflict, and another checkout is a coordination warning. The path
|
|
60
|
+
matcher respects segment boundaries, so `src/auth.ts` and `src/auth.ts.bak`
|
|
61
|
+
are distinct. A live repository reading refreshes HEAD after commit or checkout.
|
|
62
|
+
|
|
63
|
+
When a separately distributed, verified GrantTap Engine is enabled on the
|
|
64
|
+
computer, the bridge sends content-free Invocation events before its small Usage
|
|
65
|
+
buffer is trimmed. Claude Code, Codex, and Cursor transcript scanners record
|
|
66
|
+
native call identity, request, reported outcome, resource path, and explicit
|
|
67
|
+
source gaps. The Engine owns the durable journal and bounded cursor history;
|
|
68
|
+
the phone requests the latest or older page under the Project E2EE key. A
|
|
69
|
+
reported successful tool call alone does not prove a filesystem change, and
|
|
70
|
+
unavailable Engine history is reported as unavailable.
|
|
71
|
+
The bridge does not yet produce verified filesystem-change events or attach
|
|
72
|
+
revision-bound impact/consumer links to individual Invocations. It leaves that
|
|
73
|
+
evidence unknown instead of treating an edit request or reported success as a
|
|
74
|
+
confirmed content change.
|
|
75
|
+
Claude Code and Codex denials with native call IDs are recorded as separate,
|
|
76
|
+
metadata-only hook events with the applied rule and policy revision; an
|
|
77
|
+
unidentified Cursor decision is not assigned to an invented call. Script and
|
|
78
|
+
`SKILL.md` fingerprints include a content hash when the local file is readable,
|
|
79
|
+
separate from their path identity. Missing content evidence remains unknown.
|
|
57
80
|
|
|
58
81
|
<p align="center">
|
|
59
82
|
<img src="docs/images/iphone-projects-shared.png" width="200" alt="Projects: one owned by this phone, one shared by another">
|
|
60
83
|
<img src="docs/images/iphone-project-mesh.png" width="200" alt="A Project: Governance, members and computers, Mesh status, repositories, Tasks">
|
|
61
|
-
<img src="docs/images/iphone-task-route.png" width="200" alt="A Task:
|
|
84
|
+
<img src="docs/images/iphone-task-route.png" width="200" alt="A Task: executions, Runtime history, and resource claims">
|
|
62
85
|
<img src="docs/images/iphone-handoff.png" width="200" alt="Task handoff: destination, push, and readiness checks">
|
|
63
86
|
</p>
|
|
64
87
|
|
|
65
|
-
<p align="center"><em>Projects, one of them shared by another phone · a Project with its Governance, members, computers, repositories, and Tasks · a Task with
|
|
88
|
+
<p align="center"><em>Projects, one of them shared by another phone · a Project with its Governance, members, computers, repositories, and Tasks · a Task with executions, Runtime history, and claims · a handoff with its readiness checks</em></p>
|
|
66
89
|
|
|
67
90
|
For Claude Code, Codex, and Cursor, the provider hook runs inside the agent's
|
|
68
91
|
own session and sees the exact call. GrantTap attributes every `notify` to the
|
|
@@ -223,8 +246,9 @@ grok plugin marketplace add sergii-ziborov/granttap-mcp
|
|
|
223
246
|
grok plugin install granttap --trust
|
|
224
247
|
```
|
|
225
248
|
|
|
226
|
-
|
|
227
|
-
|
|
249
|
+
For Cursor, use the reviewed **GrantTap** Marketplace listing when it is visible
|
|
250
|
+
to your account, or import the public `sergii-ziborov/granttap-mcp` repository
|
|
251
|
+
once. Reload Cursor, then open
|
|
228
252
|
**Cursor Customize → MCPs → GrantTap → Authenticate**. Complete authorization
|
|
229
253
|
and any one-time QR pairing on `https://granttap.com/connect`.
|
|
230
254
|
|
|
@@ -88,6 +88,9 @@ async function handlePreToolUse(input: HookInput): Promise<void> {
|
|
|
88
88
|
recordProjectDecision(input.session_id, {
|
|
89
89
|
at: Date.now(), toolName: input.tool_name ?? "tool", reason: projectDecision.reason,
|
|
90
90
|
ruleId: projectDecision.rule_id,
|
|
91
|
+
provider: "claude", nativeCallId: input.tool_use_id,
|
|
92
|
+
policyRevision: projectDecision.policy_revision,
|
|
93
|
+
artifactHash: projectDecision.artifactHash,
|
|
91
94
|
});
|
|
92
95
|
}
|
|
93
96
|
writePermission("deny", projectDecision.reason);
|
|
@@ -43,6 +43,9 @@ async function main(): Promise<void> {
|
|
|
43
43
|
if (input.session_id) {
|
|
44
44
|
recordProjectDecision(input.session_id, {
|
|
45
45
|
at: Date.now(), toolName: input.tool_name ?? "tool", reason: decision.reason, ruleId: decision.rule_id,
|
|
46
|
+
provider: "codex", nativeCallId: input.tool_use_id,
|
|
47
|
+
policyRevision: decision.policy_revision,
|
|
48
|
+
artifactHash: decision.artifactHash,
|
|
46
49
|
});
|
|
47
50
|
}
|
|
48
51
|
deny(decision.reason);
|
|
@@ -7,6 +7,30 @@ import { ancestors } from "./descriptors";
|
|
|
7
7
|
/** Skills available globally and along the task's repository path. */
|
|
8
8
|
export function workspaceSkills(cwd: string | undefined): SkillInfo[] {
|
|
9
9
|
const found = new Map<string, SkillInfo>();
|
|
10
|
+
for (const root of skillRoots(cwd)) {
|
|
11
|
+
for (const skill of skillsIn(root)) {
|
|
12
|
+
if (!found.has(skill.name)) found.set(skill.name, skill);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The source file behind a named skill, in the same precedence as the catalog. */
|
|
19
|
+
export function skillDefinitionPath(name: string, cwd: string | undefined): string | undefined {
|
|
20
|
+
for (const root of skillRoots(cwd)) {
|
|
21
|
+
let entries: string[];
|
|
22
|
+
try { entries = readdirSync(root); }
|
|
23
|
+
catch { continue; }
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (entry.startsWith(".")) continue;
|
|
26
|
+
const path = join(root, entry, "SKILL.md");
|
|
27
|
+
if (frontmatter(path)?.name === name) return path;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function skillRoots(cwd: string | undefined): string[] {
|
|
10
34
|
const roots = [
|
|
11
35
|
join(homedir(), ".cursor", "skills-cursor"),
|
|
12
36
|
join(homedir(), ".agents", "skills"),
|
|
@@ -21,12 +45,7 @@ export function workspaceSkills(cwd: string | undefined): SkillInfo[] {
|
|
|
21
45
|
);
|
|
22
46
|
}
|
|
23
47
|
}
|
|
24
|
-
|
|
25
|
-
for (const skill of skillsIn(root)) {
|
|
26
|
-
if (!found.has(skill.name)) found.set(skill.name, skill);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
48
|
+
return roots;
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
function skillsIn(root: string): SkillInfo[] {
|
|
@@ -17,7 +17,17 @@ bypass, paused gating, and auto-accept. Codex carries ASK from `PreToolUse` to
|
|
|
17
17
|
`PermissionRequest` with a single-use exact-call marker; Cursor evaluates shell
|
|
18
18
|
and redacted MCP fingerprints directly. A disabled or unavailable engine
|
|
19
19
|
preserves the established GrantTap flow, and no hook request is retried inside
|
|
20
|
-
its
|
|
20
|
+
its 2,000 ms policy budget. Repository impact is not computed synchronously in
|
|
21
|
+
the hook; unavailable impact remains explicitly unavailable.
|
|
22
|
+
|
|
23
|
+
`invocation-ingest.ts` reads bounded complete transcript lines and exact-call
|
|
24
|
+
hook denial metadata without forwarding tool arguments, output, or reason text.
|
|
25
|
+
Cursor decisions without a native call ID stay unattributed. Events are replayed
|
|
26
|
+
with stable IDs after an IPC failure. `invocation-query.ts` returns bounded
|
|
27
|
+
Engine history to the phone under the Project key, while
|
|
28
|
+
`invocation-scope.ts` exposes one Task slice through the caller's existing
|
|
29
|
+
opaque Mesh capability. The Engine, not this bridge or Usage telemetry, owns
|
|
30
|
+
the durable journal.
|
|
21
31
|
|
|
22
32
|
The proprietary engine source and Project database do not live in this package.
|
|
23
33
|
|
|
@@ -196,6 +196,8 @@ function expectedResultOperation(
|
|
|
196
196
|
if (operation === "project.get") return "project.found";
|
|
197
197
|
if (operation === "project.list_bindings") return "project.bindings";
|
|
198
198
|
if (operation === "project.upsert_binding") return "project.binding_upserted";
|
|
199
|
+
if (operation === "invocation.observe") return "invocation.observed";
|
|
200
|
+
if (operation === "invocation.history") return "invocation.history";
|
|
199
201
|
if (operation === "policy.get") return "policy.found";
|
|
200
202
|
if (operation === "policy.apply") return "policy.applied";
|
|
201
203
|
if (operation === "policy.coverage") return "policy.coverage";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** The Engine's content-free, durable per-call evidence wire contract. */
|
|
2
|
+
export type InvocationPhase =
|
|
3
|
+
| "requested" | "reported_success" | "reported_failure" | "reported_unknown"
|
|
4
|
+
| "denied" | "change_observed" | "source_gap";
|
|
5
|
+
export type InvocationSource = "transcript" | "hook" | "filesystem" | "scanner";
|
|
6
|
+
|
|
7
|
+
export type InvocationEvent = {
|
|
8
|
+
event_id: string;
|
|
9
|
+
invocation_id: string;
|
|
10
|
+
project_id: string;
|
|
11
|
+
task_id: string;
|
|
12
|
+
execution_id: string;
|
|
13
|
+
provider: string;
|
|
14
|
+
native_call_id: string;
|
|
15
|
+
session_id?: string | null;
|
|
16
|
+
tool_name: string;
|
|
17
|
+
phase: InvocationPhase;
|
|
18
|
+
source: InvocationSource;
|
|
19
|
+
occurred_at: number;
|
|
20
|
+
repository_id?: string | null;
|
|
21
|
+
worktree?: string | null;
|
|
22
|
+
resource?: string | null;
|
|
23
|
+
revision?: string | null;
|
|
24
|
+
content_hash?: string | null;
|
|
25
|
+
capability_artifact_hash?: string | null;
|
|
26
|
+
policy_revision?: number | null;
|
|
27
|
+
policy_rule_id?: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type InvocationHistoryQuery = {
|
|
31
|
+
project_id: string;
|
|
32
|
+
task_id?: string | null;
|
|
33
|
+
after_sequence?: number | null;
|
|
34
|
+
limit?: number | null;
|
|
35
|
+
tail?: boolean;
|
|
36
|
+
before_sequence?: number | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type SequencedInvocationEvent = { sequence: number; event: InvocationEvent };
|
|
40
|
+
export type InvocationHistoryPage = {
|
|
41
|
+
events: SequencedInvocationEvent[];
|
|
42
|
+
next_sequence: number;
|
|
43
|
+
has_more: boolean;
|
|
44
|
+
previous_sequence: number;
|
|
45
|
+
has_older: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type InvocationEngineOperation =
|
|
49
|
+
| { operation: "invocation.observe"; input: InvocationEvent }
|
|
50
|
+
| { operation: "invocation.history"; input: InvocationHistoryQuery };
|
|
51
|
+
export type InvocationEngineResult =
|
|
52
|
+
| { operation: "invocation.observed"; sequence: number }
|
|
53
|
+
| { operation: "invocation.history"; page: InvocationHistoryPage };
|
|
54
|
+
|
|
55
|
+
const phases = new Set<InvocationPhase>([
|
|
56
|
+
"requested", "reported_success", "reported_failure", "reported_unknown",
|
|
57
|
+
"denied", "change_observed", "source_gap",
|
|
58
|
+
]);
|
|
59
|
+
const sources = new Set<InvocationSource>(["transcript", "hook", "filesystem", "scanner"]);
|
|
60
|
+
|
|
61
|
+
export function parseInvocationResult(result: Record<string, unknown>, invalid: () => never): boolean {
|
|
62
|
+
if (result.operation === "invocation.observed") {
|
|
63
|
+
if (!positiveInteger(result.sequence)) invalid();
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (result.operation !== "invocation.history") return false;
|
|
67
|
+
const page = record(result.page, invalid);
|
|
68
|
+
if (!Array.isArray(page.events) || page.events.length > 32
|
|
69
|
+
|| !safeInteger(page.next_sequence) || typeof page.has_more !== "boolean"
|
|
70
|
+
|| !safeInteger(page.previous_sequence) || typeof page.has_older !== "boolean") invalid();
|
|
71
|
+
let prior = 0;
|
|
72
|
+
for (const raw of page.events as unknown[]) {
|
|
73
|
+
const row = record(raw, invalid);
|
|
74
|
+
if (!positiveInteger(row.sequence) || row.sequence <= prior || row.sequence > page.next_sequence) invalid();
|
|
75
|
+
prior = row.sequence;
|
|
76
|
+
parseInvocationEvent(row.event, invalid);
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseInvocationEvent(value: unknown, invalid: () => never): void {
|
|
82
|
+
const event = record(value, invalid);
|
|
83
|
+
for (const [key, max] of [
|
|
84
|
+
["event_id", 128], ["invocation_id", 128], ["project_id", 128], ["task_id", 128],
|
|
85
|
+
["execution_id", 128], ["provider", 32], ["native_call_id", 128], ["tool_name", 160],
|
|
86
|
+
] as const) boundedString(event[key], max, invalid);
|
|
87
|
+
if (!phases.has(event.phase as InvocationPhase) || !sources.has(event.source as InvocationSource)
|
|
88
|
+
|| !safeInteger(event.occurred_at)) invalid();
|
|
89
|
+
for (const key of ["repository_id", "worktree", "resource", "revision"] as const) {
|
|
90
|
+
if (event[key] != null) boundedString(event[key], 512, invalid);
|
|
91
|
+
}
|
|
92
|
+
if (event.session_id != null) boundedString(event.session_id, 128, invalid);
|
|
93
|
+
for (const key of ["content_hash", "capability_artifact_hash"] as const) {
|
|
94
|
+
if (event[key] != null && (typeof event[key] !== "string" || !/^[a-f0-9]{64}$/i.test(event[key]))) invalid();
|
|
95
|
+
}
|
|
96
|
+
if (event.policy_revision != null && !safeInteger(event.policy_revision)) invalid();
|
|
97
|
+
if (event.policy_rule_id != null) boundedString(event.policy_rule_id, 128, invalid);
|
|
98
|
+
if (event.phase === "change_observed" && (event.source !== "filesystem"
|
|
99
|
+
|| !event.repository_id || !event.resource || !event.revision || !event.content_hash)) invalid();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function record(value: unknown, invalid: () => never): Record<string, unknown> {
|
|
103
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
|
|
104
|
+
return value as Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function safeInteger(value: unknown): value is number {
|
|
108
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function positiveInteger(value: unknown): value is number {
|
|
112
|
+
return safeInteger(value) && value > 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function boundedString(value: unknown, max: number, invalid: () => never): void {
|
|
116
|
+
if (typeof value !== "string" || value.trim() === "" || value.length > max || /[\u0000-\u001f]/.test(value)) invalid();
|
|
117
|
+
}
|
|
@@ -3,6 +3,15 @@ import {
|
|
|
3
3
|
type EnginePolicyOperation,
|
|
4
4
|
type EnginePolicyResult,
|
|
5
5
|
} from "./engine-policy-protocol";
|
|
6
|
+
import {
|
|
7
|
+
parseInvocationResult,
|
|
8
|
+
type InvocationEngineOperation,
|
|
9
|
+
type InvocationEngineResult,
|
|
10
|
+
} from "./engine-invocation-protocol";
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
InvocationEvent, InvocationHistoryPage, InvocationHistoryQuery, InvocationPhase, InvocationSource,
|
|
14
|
+
} from "./engine-invocation-protocol";
|
|
6
15
|
|
|
7
16
|
export const ENGINE_PROTOCOL_VERSION = 1 as const;
|
|
8
17
|
export const MAX_ENGINE_FRAME_BYTES = 64 * 1024;
|
|
@@ -53,7 +62,7 @@ export type EngineProjectBinding = {
|
|
|
53
62
|
last_seen_at: number;
|
|
54
63
|
};
|
|
55
64
|
|
|
56
|
-
export type EngineOperation = EnginePolicyOperation
|
|
65
|
+
export type EngineOperation = EnginePolicyOperation | InvocationEngineOperation
|
|
57
66
|
| { operation: "engine.ping" }
|
|
58
67
|
| { operation: "engine.version" }
|
|
59
68
|
| {
|
|
@@ -77,7 +86,7 @@ export type EngineRequest = {
|
|
|
77
86
|
request_id: string;
|
|
78
87
|
} & EngineOperation;
|
|
79
88
|
|
|
80
|
-
export type EngineResult = EnginePolicyResult
|
|
89
|
+
export type EngineResult = EnginePolicyResult | InvocationEngineResult
|
|
81
90
|
| { operation: "engine.pong"; engine_version: string }
|
|
82
91
|
| {
|
|
83
92
|
operation: "engine.version";
|
|
@@ -202,7 +211,8 @@ function parseResult(value: unknown): EngineResult {
|
|
|
202
211
|
result.bindings.forEach(parseBinding);
|
|
203
212
|
} else if (operation === "project.binding_upserted") {
|
|
204
213
|
parseBinding(result.binding);
|
|
205
|
-
} else if (!parsePolicyResult(result, invalidResult)
|
|
214
|
+
} else if (!parsePolicyResult(result, invalidResult)
|
|
215
|
+
&& !parseInvocationResult(result, invalidResult)) {
|
|
206
216
|
throw new EngineProtocolError("engine result operation is unsupported");
|
|
207
217
|
}
|
|
208
218
|
return result as EngineResult;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { sourceGapFact, type InvocationFact } from "./invocation-parse";
|
|
2
|
+
|
|
3
|
+
/** Metadata-only hook refusal. No arguments, outputs, or explanation text. */
|
|
4
|
+
export function parseInvocationDecision(
|
|
5
|
+
line: string, provider: "claude" | "codex" | "cursor", offset: number,
|
|
6
|
+
): InvocationFact[] {
|
|
7
|
+
let value: unknown;
|
|
8
|
+
try { value = JSON.parse(line) as unknown; }
|
|
9
|
+
catch { return [sourceGapFact(offset)]; }
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
11
|
+
return [sourceGapFact(offset)];
|
|
12
|
+
}
|
|
13
|
+
const record = value as Record<string, unknown>;
|
|
14
|
+
if (record.provider !== provider) return [];
|
|
15
|
+
if (typeof record.nativeCallId !== "string" || !record.nativeCallId.trim()
|
|
16
|
+
|| record.nativeCallId.length > 128 || /[\u0000-\u001f]/.test(record.nativeCallId)
|
|
17
|
+
|| typeof record.toolName !== "string" || !record.toolName.trim()
|
|
18
|
+
|| record.toolName.length > 160 || /[\u0000-\u001f]/.test(record.toolName)
|
|
19
|
+
|| typeof record.at !== "number" || !Number.isSafeInteger(record.at) || record.at < 0) {
|
|
20
|
+
return [sourceGapFact(offset)];
|
|
21
|
+
}
|
|
22
|
+
const revision = record.policyRevision;
|
|
23
|
+
const rule = record.ruleId;
|
|
24
|
+
const artifact = record.artifactHash;
|
|
25
|
+
return [{
|
|
26
|
+
callId: record.nativeCallId, toolName: record.toolName, phase: "denied",
|
|
27
|
+
source: "hook", occurredAt: record.at,
|
|
28
|
+
...(typeof revision === "number" && Number.isSafeInteger(revision) && revision >= 0
|
|
29
|
+
? { policyRevision: revision } : {}),
|
|
30
|
+
...(typeof rule === "string" && rule.trim() && rule.length <= 128
|
|
31
|
+
&& !/[\u0000-\u001f]/.test(rule) ? { policyRuleId: rule } : {}),
|
|
32
|
+
...(typeof artifact === "string" && /^[a-f0-9]{64}$/i.test(artifact)
|
|
33
|
+
? { capabilityArtifactHash: artifact.toLowerCase() } : {}),
|
|
34
|
+
}];
|
|
35
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { SessionInfo } from "../../../../packages/protocol/schema";
|
|
3
|
+
import { inspectRepository, type RepositoryFacts } from "../mesh/catalog";
|
|
4
|
+
import { computerId } from "../mesh/computer-identity";
|
|
5
|
+
import { repositoryRelative } from "../mesh/observed-claims";
|
|
6
|
+
import { claudeTranscriptPaths } from "../sessions/claude";
|
|
7
|
+
import { codexTranscriptPaths } from "../sessions/codex";
|
|
8
|
+
import { cursorFiles } from "../sessions/cursor/scan";
|
|
9
|
+
import { configDir } from "../config/paths";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { invocationDecisionPath } from "../policy/decision-log";
|
|
12
|
+
import { EngineClient } from "./engine-client";
|
|
13
|
+
import { engineFeatureEnabled } from "./engine-supervisor";
|
|
14
|
+
import { readInvocationBatch, type InvocationLogCursor } from "./invocation-log-reader";
|
|
15
|
+
import {
|
|
16
|
+
parseInvocationLine, sourceGapFact, type InvocationFact, type InvocationParseState,
|
|
17
|
+
} from "./invocation-parse";
|
|
18
|
+
import type { InvocationEvent } from "./engine-invocation-protocol";
|
|
19
|
+
import { parseInvocationDecision } from "./invocation-decision-parse";
|
|
20
|
+
|
|
21
|
+
type Provider = "claude" | "codex" | "cursor";
|
|
22
|
+
type SourceState = { cursor: InvocationLogCursor; parse: InvocationParseState };
|
|
23
|
+
type Client = Pick<EngineClient, "request">;
|
|
24
|
+
|
|
25
|
+
export type InvocationIngestOptions = {
|
|
26
|
+
client: Client;
|
|
27
|
+
enabled: () => boolean;
|
|
28
|
+
computer: () => string;
|
|
29
|
+
paths: (session: SessionInfo) => string[];
|
|
30
|
+
inspect: (cwd: string) => RepositoryFacts;
|
|
31
|
+
decisionPath?: (session: SessionInfo) => string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function transcriptPaths(session: SessionInfo): string[] {
|
|
35
|
+
if (session.agent === "claude") return claudeTranscriptPaths(session.sessionId);
|
|
36
|
+
if (session.agent === "codex") return codexTranscriptPaths(session.sessionId);
|
|
37
|
+
if (session.agent === "cursor") return cursorFiles(session.sessionId)?.map((file) => file.path) ?? [];
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createInvocationIngestor(options: InvocationIngestOptions) {
|
|
42
|
+
const states = new Map<string, SourceState>();
|
|
43
|
+
const decisionStates = new Map<string, InvocationLogCursor>();
|
|
44
|
+
return {
|
|
45
|
+
async ingest(sessions: readonly SessionInfo[]): Promise<number> {
|
|
46
|
+
if (!options.enabled()) return 0;
|
|
47
|
+
const candidates = sessions.filter((session) => session.projectId && session.taskId
|
|
48
|
+
&& ["claude", "codex", "cursor"].includes(session.agent));
|
|
49
|
+
if (candidates.length === 0) return 0;
|
|
50
|
+
try { await options.client.request({ operation: "engine.ping" }, { timeoutMs: 200 }); }
|
|
51
|
+
catch { return 0; }
|
|
52
|
+
let accepted = 0;
|
|
53
|
+
for (const session of candidates) {
|
|
54
|
+
const provider = session.agent as Provider;
|
|
55
|
+
const facts = session.cwd ? options.inspect(session.cwd) : undefined;
|
|
56
|
+
for (const path of options.paths(session)) {
|
|
57
|
+
const key = `${provider}\0${session.sessionId}\0${path}`;
|
|
58
|
+
const known = states.get(key);
|
|
59
|
+
let batch;
|
|
60
|
+
try { batch = readInvocationBatch(path, known?.cursor); }
|
|
61
|
+
catch { continue; }
|
|
62
|
+
const parse: InvocationParseState = {
|
|
63
|
+
pending: new Map(known?.parse.pending ?? []),
|
|
64
|
+
};
|
|
65
|
+
const records = [
|
|
66
|
+
...batch.gaps.map((offset) => ({ offset, gap: true as const })),
|
|
67
|
+
...batch.lines.map((row) => ({ ...row, gap: false as const })),
|
|
68
|
+
].sort((a, b) => a.offset - b.offset || Number(b.gap) - Number(a.gap));
|
|
69
|
+
const thread = hash([path]);
|
|
70
|
+
const events: InvocationEvent[] = [];
|
|
71
|
+
for (const record of records) {
|
|
72
|
+
if (record.gap) parse.pending.clear();
|
|
73
|
+
const observed = record.gap
|
|
74
|
+
? [sourceGapFact(record.offset)]
|
|
75
|
+
: parseInvocationLine(provider, record.line, parse, record.offset);
|
|
76
|
+
for (const fact of observed) {
|
|
77
|
+
events.push(eventFor(session, provider, options.computer(), thread, record.offset, fact, facts));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
for (const event of events) {
|
|
82
|
+
await options.client.request({ operation: "invocation.observe", input: event });
|
|
83
|
+
accepted += 1;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// No cursor advance: the next pass replays this batch under stable
|
|
87
|
+
// event IDs. Engine deduplicates already committed evidence.
|
|
88
|
+
return accepted;
|
|
89
|
+
}
|
|
90
|
+
states.set(key, { cursor: batch.next, parse });
|
|
91
|
+
}
|
|
92
|
+
const decisionPath = options.decisionPath?.(session);
|
|
93
|
+
if (decisionPath) {
|
|
94
|
+
const key = `${provider}\0${session.sessionId}\0${decisionPath}`;
|
|
95
|
+
let batch;
|
|
96
|
+
try { batch = readInvocationBatch(decisionPath, decisionStates.get(key)); }
|
|
97
|
+
catch { continue; }
|
|
98
|
+
const thread = hash([decisionPath]);
|
|
99
|
+
const events = [
|
|
100
|
+
...batch.gaps.map((offset) => ({ offset, gap: true as const })),
|
|
101
|
+
...batch.lines.map((row) => ({ ...row, gap: false as const })),
|
|
102
|
+
].sort((a, b) => a.offset - b.offset).flatMap((record) =>
|
|
103
|
+
(record.gap ? [sourceGapFact(record.offset)]
|
|
104
|
+
: parseInvocationDecision(record.line, provider, record.offset))
|
|
105
|
+
.map((fact) => eventFor(session, provider, options.computer(), thread, record.offset, fact, facts)));
|
|
106
|
+
try {
|
|
107
|
+
for (const event of events) {
|
|
108
|
+
await options.client.request({ operation: "invocation.observe", input: event });
|
|
109
|
+
accepted += 1;
|
|
110
|
+
}
|
|
111
|
+
} catch { return accepted; }
|
|
112
|
+
decisionStates.set(key, batch.next);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return accepted;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function eventFor(
|
|
121
|
+
session: SessionInfo, provider: Provider, computer: string,
|
|
122
|
+
thread: string, offset: number, fact: InvocationFact, repository?: RepositoryFacts,
|
|
123
|
+
): InvocationEvent {
|
|
124
|
+
// A native session may be observed again after handoff. The computer is part
|
|
125
|
+
// of the Execution identity, while Cursor child transcripts also need their
|
|
126
|
+
// thread path to avoid reusing a call ID from a sibling conversation.
|
|
127
|
+
const invocationId = hash([computer, provider, session.sessionId,
|
|
128
|
+
provider === "cursor" || fact.phase === "source_gap" ? thread : "", fact.callId]);
|
|
129
|
+
const resource = fact.resource && repository
|
|
130
|
+
? repositoryRelative(fact.resource, repository.root) : undefined;
|
|
131
|
+
return {
|
|
132
|
+
event_id: hash([invocationId, thread, String(offset), fact.phase, fact.source,
|
|
133
|
+
resource ?? "", String(fact.occurredAt)]),
|
|
134
|
+
invocation_id: invocationId,
|
|
135
|
+
project_id: session.projectId!,
|
|
136
|
+
task_id: session.taskId!,
|
|
137
|
+
execution_id: hash([computer, provider, session.sessionId]),
|
|
138
|
+
provider, native_call_id: fact.callId, session_id: session.sessionId,
|
|
139
|
+
tool_name: fact.toolName,
|
|
140
|
+
phase: fact.phase, source: fact.source, occurred_at: fact.occurredAt,
|
|
141
|
+
repository_id: repository?.canonicalRepositoryId,
|
|
142
|
+
worktree: session.worktree ?? repository?.worktree,
|
|
143
|
+
resource,
|
|
144
|
+
policy_revision: fact.policyRevision,
|
|
145
|
+
policy_rule_id: fact.policyRuleId,
|
|
146
|
+
capability_artifact_hash: fact.capabilityArtifactHash,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hash(parts: string[]): string {
|
|
151
|
+
return createHash("sha256").update(parts.join("\0")).digest("hex");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const defaultClient = new EngineClient({ socketPath: join(configDir(), "engine.sock") });
|
|
155
|
+
const defaultIngestor = createInvocationIngestor({
|
|
156
|
+
client: defaultClient, enabled: engineFeatureEnabled, computer: computerId,
|
|
157
|
+
paths: transcriptPaths, inspect: inspectRepository,
|
|
158
|
+
decisionPath: (session) => invocationDecisionPath(session.sessionId),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
let ongoing: Promise<number> | undefined;
|
|
162
|
+
|
|
163
|
+
export function ingestRuntimeInvocations(sessions: readonly SessionInfo[]): Promise<number> {
|
|
164
|
+
if (ongoing) return ongoing;
|
|
165
|
+
ongoing = defaultIngestor.ingest(sessions).finally(() => { ongoing = undefined; });
|
|
166
|
+
return ongoing;
|
|
167
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { closeSync, lstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const MAX_INITIAL_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
const MAX_BATCH_BYTES = 1024 * 1024;
|
|
5
|
+
const MAX_LINE_BYTES = 256 * 1024;
|
|
6
|
+
|
|
7
|
+
export type InvocationLogCursor = {
|
|
8
|
+
dev: number;
|
|
9
|
+
ino: number;
|
|
10
|
+
offset: number;
|
|
11
|
+
lineStart: number;
|
|
12
|
+
partial: Buffer;
|
|
13
|
+
skipping: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type InvocationLogBatch = {
|
|
17
|
+
lines: Array<{ line: string; offset: number }>;
|
|
18
|
+
gaps: number[];
|
|
19
|
+
next: InvocationLogCursor;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** A bounded, resumable JSONL read; the caller commits `next` only after Engine accepts the batch. */
|
|
23
|
+
export function readInvocationBatch(path: string, cursor?: InvocationLogCursor): InvocationLogBatch {
|
|
24
|
+
const stat = lstatSync(path);
|
|
25
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("transcript is not a regular file");
|
|
26
|
+
const rotated = cursor && (cursor.dev !== stat.dev || cursor.ino !== stat.ino || stat.size < cursor.offset);
|
|
27
|
+
const reuse = cursor && !rotated;
|
|
28
|
+
const initial = reuse ? cursor.offset : Math.max(0, stat.size - MAX_INITIAL_BYTES);
|
|
29
|
+
const gaps = rotated ? [cursor.offset] : initial > 0 ? [initial] : [];
|
|
30
|
+
const start = initial;
|
|
31
|
+
const length = Math.min(MAX_BATCH_BYTES, Math.max(0, stat.size - start));
|
|
32
|
+
const chunk = Buffer.allocUnsafe(length);
|
|
33
|
+
let read = 0;
|
|
34
|
+
if (length > 0) {
|
|
35
|
+
const fd = openSync(path, "r");
|
|
36
|
+
try { read = readSync(fd, chunk, 0, length, start); }
|
|
37
|
+
finally { closeSync(fd); }
|
|
38
|
+
}
|
|
39
|
+
const offset = start + read;
|
|
40
|
+
const prior = reuse ? cursor.partial : Buffer.alloc(0);
|
|
41
|
+
const combined = Buffer.concat([prior, chunk.subarray(0, read)]);
|
|
42
|
+
const base = start - prior.length;
|
|
43
|
+
const lines: InvocationLogBatch["lines"] = [];
|
|
44
|
+
let lineStart = reuse ? cursor.lineStart : start;
|
|
45
|
+
let skipping = reuse ? cursor.skipping : initial > 0;
|
|
46
|
+
let segmentStart = 0;
|
|
47
|
+
for (let index = 0; index < combined.length; index += 1) {
|
|
48
|
+
if (combined[index] !== 10) continue;
|
|
49
|
+
const size = index - segmentStart;
|
|
50
|
+
if (!skipping && size <= MAX_LINE_BYTES) {
|
|
51
|
+
const line = combined.subarray(segmentStart, index).toString("utf8");
|
|
52
|
+
lines.push({ line, offset: lineStart });
|
|
53
|
+
} else if (!skipping) {
|
|
54
|
+
gaps.push(lineStart);
|
|
55
|
+
}
|
|
56
|
+
segmentStart = index + 1;
|
|
57
|
+
lineStart = base + segmentStart;
|
|
58
|
+
skipping = false;
|
|
59
|
+
}
|
|
60
|
+
const trailing = combined.subarray(segmentStart);
|
|
61
|
+
let partial = skipping ? Buffer.alloc(0) : Buffer.from(trailing);
|
|
62
|
+
if (!skipping && partial.length > MAX_LINE_BYTES) {
|
|
63
|
+
gaps.push(lineStart);
|
|
64
|
+
skipping = true;
|
|
65
|
+
partial = Buffer.alloc(0);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
lines, gaps,
|
|
69
|
+
next: { dev: stat.dev, ino: stat.ino, offset, lineStart, partial, skipping },
|
|
70
|
+
};
|
|
71
|
+
}
|