@flyingrobots/graft 0.3.1
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/CHANGELOG.md +218 -0
- package/LICENSE +190 -0
- package/NOTICE +4 -0
- package/README.md +119 -0
- package/bin/graft.js +11 -0
- package/docs/GUIDE.md +374 -0
- package/package.json +76 -0
- package/src/adapters/canonical-json.ts +56 -0
- package/src/adapters/node-fs.ts +39 -0
- package/src/git/diff.ts +96 -0
- package/src/guards/stream-boundary.ts +110 -0
- package/src/hooks/posttooluse-read.ts +107 -0
- package/src/hooks/pretooluse-read.ts +88 -0
- package/src/hooks/shared.ts +168 -0
- package/src/mcp/cache.ts +94 -0
- package/src/mcp/cached-file.ts +38 -0
- package/src/mcp/context.ts +52 -0
- package/src/mcp/metrics.ts +53 -0
- package/src/mcp/receipt.ts +83 -0
- package/src/mcp/server.ts +166 -0
- package/src/mcp/stdio.ts +6 -0
- package/src/mcp/tools/budget.ts +20 -0
- package/src/mcp/tools/changed-since.ts +68 -0
- package/src/mcp/tools/doctor.ts +20 -0
- package/src/mcp/tools/explain.ts +80 -0
- package/src/mcp/tools/file-outline.ts +57 -0
- package/src/mcp/tools/graft-diff.ts +24 -0
- package/src/mcp/tools/read-range.ts +21 -0
- package/src/mcp/tools/run-capture.ts +67 -0
- package/src/mcp/tools/safe-read.ts +135 -0
- package/src/mcp/tools/state.ts +30 -0
- package/src/mcp/tools/stats.ts +20 -0
- package/src/metrics/logger.ts +69 -0
- package/src/metrics/types.ts +12 -0
- package/src/operations/file-outline.ts +38 -0
- package/src/operations/graft-diff.ts +117 -0
- package/src/operations/read-range.ts +65 -0
- package/src/operations/safe-read.ts +96 -0
- package/src/operations/state.ts +33 -0
- package/src/parser/diff.ts +142 -0
- package/src/parser/lang.ts +12 -0
- package/src/parser/outline.ts +327 -0
- package/src/parser/types.ts +67 -0
- package/src/policy/evaluate.ts +178 -0
- package/src/policy/graftignore.ts +6 -0
- package/src/policy/types.ts +86 -0
- package/src/ports/codec.ts +13 -0
- package/src/ports/filesystem.ts +17 -0
- package/src/session/tracker.ts +114 -0
- package/src/session/types.ts +20 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Stream/Port boundary guards — enforce the Two-Case Rule at runtime
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// Law: Streams explore. Ports decide.
|
|
6
|
+
//
|
|
7
|
+
// Ports return bounded artifacts (Promise<T>). Streams return
|
|
8
|
+
// unbounded traversals (AsyncIterable<T>). These two shapes MUST
|
|
9
|
+
// NOT cross. A port that returns a stream is a lie. A stream that
|
|
10
|
+
// returns a single value is a waste.
|
|
11
|
+
//
|
|
12
|
+
// These guards make violations throw, not silently misbehave.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns true if the value implements the async iterable protocol.
|
|
17
|
+
*/
|
|
18
|
+
export function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
|
19
|
+
return (
|
|
20
|
+
value !== null &&
|
|
21
|
+
typeof value === "object" &&
|
|
22
|
+
typeof (value as Record<symbol, unknown>)[Symbol.asyncIterator] === "function"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Throws if the value is an AsyncIterable. Use at port return boundaries
|
|
28
|
+
* to prevent streams from leaking into persistence layers.
|
|
29
|
+
*
|
|
30
|
+
* @param value - The value to check
|
|
31
|
+
* @param context - Description of where the check is (e.g. "FileSystem.readFile()")
|
|
32
|
+
*/
|
|
33
|
+
export function assertNotStream(value: unknown, context: string): void {
|
|
34
|
+
if (isAsyncIterable(value)) {
|
|
35
|
+
throw new TypeError(
|
|
36
|
+
`${context} produced a stream where a bounded value was required. ` +
|
|
37
|
+
`Ports return artifacts, not traversals.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Throws if the value is NOT an AsyncIterable. Use at stream transform
|
|
44
|
+
* entry points to prevent bounded values from entering traversal pipelines.
|
|
45
|
+
*
|
|
46
|
+
* @param value - The value to check
|
|
47
|
+
* @param context - Description of where the check is (e.g. "BlobWriteTransform.apply()")
|
|
48
|
+
*/
|
|
49
|
+
export function assertStream(value: unknown, context: string): asserts value is AsyncIterable<unknown> {
|
|
50
|
+
if (!isAsyncIterable(value)) {
|
|
51
|
+
const actual = value === null ? "null"
|
|
52
|
+
: Array.isArray(value) ? "Array"
|
|
53
|
+
: typeof value;
|
|
54
|
+
throw new TypeError(
|
|
55
|
+
`${context} expected AsyncIterable but received ${actual}. ` +
|
|
56
|
+
`Streams traverse, they do not materialize.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Wraps a port method to guard its return value against accidental streams.
|
|
63
|
+
* Use this to retrofit existing ports without modifying their interfaces.
|
|
64
|
+
*
|
|
65
|
+
* @param portName - Name of the port class (for error messages)
|
|
66
|
+
* @param methodName - Name of the method being guarded
|
|
67
|
+
* @param fn - The original method
|
|
68
|
+
*/
|
|
69
|
+
export function guardPortReturn<TArgs extends unknown[], TReturn>(
|
|
70
|
+
portName: string,
|
|
71
|
+
methodName: string,
|
|
72
|
+
fn: (...args: TArgs) => Promise<TReturn>,
|
|
73
|
+
): (...args: TArgs) => Promise<TReturn> {
|
|
74
|
+
return async (...args: TArgs): Promise<TReturn> => {
|
|
75
|
+
const result = await fn(...args);
|
|
76
|
+
assertNotStream(result, `${portName}.${methodName}()`);
|
|
77
|
+
return result;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Wraps an entire port interface via Proxy. Every method call is
|
|
83
|
+
* intercepted: if the return is a Promise, it's awaited and checked
|
|
84
|
+
* with assertNotStream. Synchronous returns are checked directly.
|
|
85
|
+
*
|
|
86
|
+
* One line to guard a whole port instead of per-method wiring.
|
|
87
|
+
*
|
|
88
|
+
* @param portName - Name of the port (for error messages)
|
|
89
|
+
* @param port - The port instance to guard
|
|
90
|
+
*/
|
|
91
|
+
export function guardedPort<T extends object>(portName: string, port: T): T {
|
|
92
|
+
return new Proxy(port, {
|
|
93
|
+
get(target: T, prop: string | symbol, receiver: unknown): unknown {
|
|
94
|
+
const value = Reflect.get(target, prop, receiver) as unknown;
|
|
95
|
+
if (typeof value !== "function") return value;
|
|
96
|
+
const methodName = typeof prop === "symbol" ? prop.toString() : prop;
|
|
97
|
+
return function (this: unknown, ...args: unknown[]): unknown {
|
|
98
|
+
const result = (value as (...a: unknown[]) => unknown).apply(target, args);
|
|
99
|
+
if (result instanceof Promise) {
|
|
100
|
+
return result.then((resolved: unknown) => {
|
|
101
|
+
assertNotStream(resolved, `${portName}.${methodName}()`);
|
|
102
|
+
return resolved;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
assertNotStream(result, `${portName}.${methodName}()`);
|
|
106
|
+
return result;
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// PostToolUse hook for Read — educates the agent on context cost
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// After a Read completes, evaluates what safe_read would have done and
|
|
6
|
+
// tells the agent the cost difference. Does not block — just feedback.
|
|
7
|
+
//
|
|
8
|
+
// The agent sees messages like:
|
|
9
|
+
// "[graft] You just read 450 lines (18KB). safe_read would have
|
|
10
|
+
// returned a 2KB outline, saving 16KB of context."
|
|
11
|
+
//
|
|
12
|
+
// This teaches the agent to prefer graft's MCP tools voluntarily.
|
|
13
|
+
//
|
|
14
|
+
// Invoked as: node --import tsx src/hooks/posttooluse-read.ts
|
|
15
|
+
// Receives JSON on stdin from Claude Code hooks system.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { evaluatePolicy, STATIC_THRESHOLDS } from "../policy/evaluate.js";
|
|
21
|
+
import { ContentResult, RefusedResult } from "../policy/types.js";
|
|
22
|
+
import { loadGraftignore } from "../policy/graftignore.js";
|
|
23
|
+
import { HookInput, HookOutput, safeRelativePath, runHook } from "./shared.js";
|
|
24
|
+
|
|
25
|
+
export { HookInput, HookOutput };
|
|
26
|
+
|
|
27
|
+
export async function handlePostReadHook(input: HookInput): Promise<HookOutput> {
|
|
28
|
+
const filePath = input.tool_input.file_path;
|
|
29
|
+
|
|
30
|
+
// Path outside project — no feedback
|
|
31
|
+
const relPath = safeRelativePath(input.cwd, filePath);
|
|
32
|
+
if (relPath === null) {
|
|
33
|
+
return new HookOutput(0, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Read file to get dimensions
|
|
37
|
+
let rawContent: string;
|
|
38
|
+
try {
|
|
39
|
+
rawContent = fs.readFileSync(filePath, "utf-8");
|
|
40
|
+
} catch {
|
|
41
|
+
return new HookOutput(0, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lines = rawContent.split("\n");
|
|
45
|
+
const bytes = Buffer.byteLength(rawContent, "utf-8");
|
|
46
|
+
|
|
47
|
+
// Load .graftignore patterns
|
|
48
|
+
let graftignorePatterns: string[] | undefined;
|
|
49
|
+
try {
|
|
50
|
+
const ignoreFile = fs.readFileSync(
|
|
51
|
+
path.join(input.cwd, ".graftignore"),
|
|
52
|
+
"utf-8",
|
|
53
|
+
);
|
|
54
|
+
graftignorePatterns = loadGraftignore(ignoreFile);
|
|
55
|
+
} catch {
|
|
56
|
+
// No .graftignore
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Evaluate what safe_read would have done
|
|
60
|
+
const policy = evaluatePolicy(
|
|
61
|
+
{ path: relPath, lines: lines.length, bytes },
|
|
62
|
+
{ graftignorePatterns },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Small file — no feedback needed, Read was the right call
|
|
66
|
+
if (policy instanceof ContentResult) {
|
|
67
|
+
return new HookOutput(0, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Refused — PreToolUse should have caught this, but just in case
|
|
71
|
+
if (policy instanceof RefusedResult) {
|
|
72
|
+
return new HookOutput(0, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Outline projection — the agent just dumped a large file into context
|
|
76
|
+
// when safe_read would have returned a compact outline
|
|
77
|
+
const { detectLang } = await import("../parser/lang.js");
|
|
78
|
+
const lang = detectLang(filePath);
|
|
79
|
+
if (lang === null) {
|
|
80
|
+
// Non-JS/TS — no outline available, Read was reasonable
|
|
81
|
+
return new HookOutput(0, "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { CanonicalJsonCodec } = await import("../adapters/canonical-json.js");
|
|
85
|
+
const { extractOutline } = await import("../parser/outline.js");
|
|
86
|
+
const codec = new CanonicalJsonCodec();
|
|
87
|
+
const outline = extractOutline(rawContent, lang);
|
|
88
|
+
const outlineBytes = Buffer.byteLength(codec.encode(outline), "utf-8");
|
|
89
|
+
const saved = bytes - outlineBytes;
|
|
90
|
+
const savedKb = (saved / 1024).toFixed(1);
|
|
91
|
+
const bytesKb = (bytes / 1024).toFixed(1);
|
|
92
|
+
|
|
93
|
+
return new HookOutput(0, [
|
|
94
|
+
`[graft] You just read ${String(lines.length)} lines (${bytesKb}KB) into context.`,
|
|
95
|
+
`safe_read would have returned a structural outline (${String(outlineBytes)} bytes),`,
|
|
96
|
+
`saving ${savedKb}KB of context. Threshold: ${String(STATIC_THRESHOLDS.lines)} lines / ${String(STATIC_THRESHOLDS.bytes / 1024)}KB.`,
|
|
97
|
+
"",
|
|
98
|
+
"Consider using graft's safe_read tool for large files —",
|
|
99
|
+
"it returns outlines with jump tables for targeted read_range.",
|
|
100
|
+
].join("\n"));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Script entry point — exit 0 on failure, never block on post-hook errors
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
runHook(handlePostReadHook, 0);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// PreToolUse hook for Read — blocks banned files only
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// Intercepts Claude Code's Read tool and evaluates graft policy:
|
|
6
|
+
// - Refused (banned file): exit 2 — hard block with refusal reason
|
|
7
|
+
// - Everything else: exit 0 — let native Read proceed
|
|
8
|
+
//
|
|
9
|
+
// Banned files (.env, binaries, lockfiles, minified, build output,
|
|
10
|
+
// .graftignore matches) are the only hard enforcement. Large file
|
|
11
|
+
// governance is handled by the PostToolUse hook via education.
|
|
12
|
+
//
|
|
13
|
+
// Invoked as: node --import tsx src/hooks/pretooluse-read.ts
|
|
14
|
+
// Receives JSON on stdin from Claude Code hooks system.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { evaluatePolicy } from "../policy/evaluate.js";
|
|
20
|
+
import { RefusedResult } from "../policy/types.js";
|
|
21
|
+
import { loadGraftignore } from "../policy/graftignore.js";
|
|
22
|
+
import { HookInput, HookOutput, safeRelativePath, runHook } from "./shared.js";
|
|
23
|
+
|
|
24
|
+
export { HookInput, HookOutput };
|
|
25
|
+
|
|
26
|
+
export function handleReadHook(input: HookInput): HookOutput {
|
|
27
|
+
const filePath = input.tool_input.file_path;
|
|
28
|
+
|
|
29
|
+
// Path outside project — let Read handle it, not our concern
|
|
30
|
+
const relPath = safeRelativePath(input.cwd, filePath);
|
|
31
|
+
if (relPath === null) {
|
|
32
|
+
return new HookOutput(0, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Read file to get dimensions for policy
|
|
36
|
+
let rawContent: string;
|
|
37
|
+
try {
|
|
38
|
+
rawContent = fs.readFileSync(filePath, "utf-8");
|
|
39
|
+
} catch {
|
|
40
|
+
// File errors (ENOENT, EACCES, EISDIR) — let Read handle natively
|
|
41
|
+
return new HookOutput(0, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lines = rawContent.split("\n");
|
|
45
|
+
const bytes = Buffer.byteLength(rawContent, "utf-8");
|
|
46
|
+
|
|
47
|
+
// Load .graftignore patterns
|
|
48
|
+
let graftignorePatterns: string[] | undefined;
|
|
49
|
+
try {
|
|
50
|
+
const ignoreFile = fs.readFileSync(
|
|
51
|
+
path.join(input.cwd, ".graftignore"),
|
|
52
|
+
"utf-8",
|
|
53
|
+
);
|
|
54
|
+
graftignorePatterns = loadGraftignore(ignoreFile);
|
|
55
|
+
} catch {
|
|
56
|
+
// No .graftignore — that's fine
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Evaluate policy
|
|
60
|
+
const policy = evaluatePolicy(
|
|
61
|
+
{ path: relPath, lines: lines.length, bytes },
|
|
62
|
+
{ graftignorePatterns },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Only block refused files — everything else passes through
|
|
66
|
+
if (policy instanceof RefusedResult) {
|
|
67
|
+
const nextSteps = policy.next.map((n) => ` - ${n}`).join("\n");
|
|
68
|
+
return new HookOutput(2, [
|
|
69
|
+
`[graft] Refused: ${policy.reason}`,
|
|
70
|
+
policy.reasonDetail,
|
|
71
|
+
"",
|
|
72
|
+
"Next steps:",
|
|
73
|
+
nextSteps,
|
|
74
|
+
"",
|
|
75
|
+
"Graft tools: use file_outline to see the file's structure,",
|
|
76
|
+
"or safe_read for a policy-aware read with caching.",
|
|
77
|
+
].join("\n"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Content or outline — let Read proceed. PostToolUse will educate.
|
|
81
|
+
return new HookOutput(0, "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Script entry point — exit 2 on failure to block unsafe reads
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
runHook(handleReadHook, 2);
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Shared utilities for Claude Code hooks
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
|
|
7
|
+
/** Maximum stdin bytes before rejecting (1 MB — generous for JSON). */
|
|
8
|
+
const MAX_STDIN_BYTES = 1_048_576;
|
|
9
|
+
|
|
10
|
+
export class HookInput {
|
|
11
|
+
readonly session_id: string;
|
|
12
|
+
readonly cwd: string;
|
|
13
|
+
readonly hook_event_name: string;
|
|
14
|
+
readonly tool_name: string;
|
|
15
|
+
readonly tool_input: {
|
|
16
|
+
readonly file_path: string;
|
|
17
|
+
readonly offset?: number;
|
|
18
|
+
readonly limit?: number;
|
|
19
|
+
};
|
|
20
|
+
readonly tool_result?: string;
|
|
21
|
+
|
|
22
|
+
constructor(opts: {
|
|
23
|
+
session_id: string;
|
|
24
|
+
cwd: string;
|
|
25
|
+
hook_event_name: string;
|
|
26
|
+
tool_name: string;
|
|
27
|
+
tool_input: { file_path: string; offset?: number; limit?: number };
|
|
28
|
+
tool_result?: string;
|
|
29
|
+
}) {
|
|
30
|
+
if (opts.session_id.length === 0) {
|
|
31
|
+
throw new Error("HookInput: session_id must be non-empty");
|
|
32
|
+
}
|
|
33
|
+
if (opts.cwd.length === 0) {
|
|
34
|
+
throw new Error("HookInput: cwd must be non-empty");
|
|
35
|
+
}
|
|
36
|
+
if (opts.tool_input.file_path.length === 0) {
|
|
37
|
+
throw new Error("HookInput: tool_input.file_path must be non-empty");
|
|
38
|
+
}
|
|
39
|
+
this.session_id = opts.session_id;
|
|
40
|
+
this.cwd = opts.cwd;
|
|
41
|
+
this.hook_event_name = opts.hook_event_name;
|
|
42
|
+
this.tool_name = opts.tool_name;
|
|
43
|
+
this.tool_input = Object.freeze({ ...opts.tool_input });
|
|
44
|
+
if (opts.tool_result !== undefined) this.tool_result = opts.tool_result;
|
|
45
|
+
Object.freeze(this);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class HookOutput {
|
|
50
|
+
readonly exitCode: number;
|
|
51
|
+
readonly stderr: string;
|
|
52
|
+
|
|
53
|
+
constructor(exitCode: number, stderr: string) {
|
|
54
|
+
this.exitCode = exitCode;
|
|
55
|
+
this.stderr = stderr;
|
|
56
|
+
Object.freeze(this);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reads stdin with a size guard. Throws if input exceeds MAX_STDIN_BYTES.
|
|
62
|
+
* Accumulates raw buffers to avoid corrupting multi-byte UTF-8 characters
|
|
63
|
+
* that may be split across chunk boundaries.
|
|
64
|
+
*/
|
|
65
|
+
export async function readStdin(): Promise<string> {
|
|
66
|
+
const chunks: Buffer[] = [];
|
|
67
|
+
let totalBytes = 0;
|
|
68
|
+
for await (const chunk of process.stdin) {
|
|
69
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf-8");
|
|
70
|
+
totalBytes += buf.length;
|
|
71
|
+
if (totalBytes > MAX_STDIN_BYTES) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`stdin exceeded ${String(MAX_STDIN_BYTES)} bytes — aborting`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
chunks.push(buf);
|
|
77
|
+
}
|
|
78
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parses and validates hook input from a raw JSON string.
|
|
83
|
+
* Returns a frozen HookInput instance.
|
|
84
|
+
*/
|
|
85
|
+
export function parseHookInput(raw: string): HookInput {
|
|
86
|
+
const parsed: unknown = JSON.parse(raw);
|
|
87
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
88
|
+
throw new Error("Hook input must be a JSON object");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const obj = parsed as Record<string, unknown>;
|
|
92
|
+
|
|
93
|
+
const sessionId = obj["session_id"];
|
|
94
|
+
if (typeof sessionId !== "string") {
|
|
95
|
+
throw new Error("Hook input missing session_id");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const cwd = obj["cwd"];
|
|
99
|
+
if (typeof cwd !== "string") {
|
|
100
|
+
throw new Error("Hook input missing cwd");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rawToolInput = obj["tool_input"];
|
|
104
|
+
if (typeof rawToolInput !== "object" || rawToolInput === null) {
|
|
105
|
+
throw new Error("Hook input missing tool_input");
|
|
106
|
+
}
|
|
107
|
+
const toolInput = rawToolInput as Record<string, unknown>;
|
|
108
|
+
|
|
109
|
+
const filePath = toolInput["file_path"];
|
|
110
|
+
if (typeof filePath !== "string") {
|
|
111
|
+
throw new Error("Hook input missing tool_input.file_path");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const hookEventName = obj["hook_event_name"];
|
|
115
|
+
const toolName = obj["tool_name"];
|
|
116
|
+
const toolResult = obj["tool_result"];
|
|
117
|
+
const offset = toolInput["offset"];
|
|
118
|
+
const limit = toolInput["limit"];
|
|
119
|
+
|
|
120
|
+
return new HookInput({
|
|
121
|
+
session_id: sessionId,
|
|
122
|
+
cwd,
|
|
123
|
+
hook_event_name: typeof hookEventName === "string" ? hookEventName : "",
|
|
124
|
+
tool_name: typeof toolName === "string" ? toolName : "",
|
|
125
|
+
tool_input: {
|
|
126
|
+
file_path: filePath,
|
|
127
|
+
...(typeof offset === "number" ? { offset } : {}),
|
|
128
|
+
...(typeof limit === "number" ? { limit } : {}),
|
|
129
|
+
},
|
|
130
|
+
...(typeof toolResult === "string" ? { tool_result: toolResult } : {}),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns a cwd-relative path, or null if the path is outside the cwd.
|
|
136
|
+
* Prevents path traversal attacks and handles the path.relative() edge case
|
|
137
|
+
* where paths outside cwd produce absolute or '../' prefixed results.
|
|
138
|
+
*/
|
|
139
|
+
export function safeRelativePath(cwd: string, filePath: string): string | null {
|
|
140
|
+
const rel = path.relative(cwd, filePath);
|
|
141
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return rel;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Wraps a hook's main function with stdin reading, input parsing,
|
|
149
|
+
* and error handling. Logs full stack traces on failure.
|
|
150
|
+
*/
|
|
151
|
+
export function runHook(
|
|
152
|
+
handler: (input: HookInput) => HookOutput | Promise<HookOutput>,
|
|
153
|
+
failExitCode: number,
|
|
154
|
+
): void {
|
|
155
|
+
const run = async (): Promise<void> => {
|
|
156
|
+
const raw = await readStdin();
|
|
157
|
+
const input = parseHookInput(raw);
|
|
158
|
+
const output = await handler(input);
|
|
159
|
+
if (output.stderr.length > 0) process.stderr.write(output.stderr);
|
|
160
|
+
process.exit(output.exitCode);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
run().catch((err: unknown) => {
|
|
164
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
165
|
+
process.stderr.write(`[graft] Hook error: ${detail}`);
|
|
166
|
+
process.exit(failExitCode);
|
|
167
|
+
});
|
|
168
|
+
}
|
package/src/mcp/cache.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Observation cache — tracks file content seen by the agent
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import * as crypto from "node:crypto";
|
|
6
|
+
import type { OutlineEntry, JumpEntry } from "../parser/types.js";
|
|
7
|
+
|
|
8
|
+
export function hashContent(content: string): string {
|
|
9
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class Observation {
|
|
13
|
+
readonly contentHash: string;
|
|
14
|
+
readonly outline: readonly OutlineEntry[];
|
|
15
|
+
readonly jumpTable: readonly JumpEntry[];
|
|
16
|
+
readonly actual: Readonly<{ lines: number; bytes: number }>;
|
|
17
|
+
readonly firstReadAt: string;
|
|
18
|
+
private _readCount: number;
|
|
19
|
+
private _lastReadAt: string;
|
|
20
|
+
|
|
21
|
+
constructor(opts: {
|
|
22
|
+
contentHash: string;
|
|
23
|
+
outline: readonly OutlineEntry[];
|
|
24
|
+
jumpTable: readonly JumpEntry[];
|
|
25
|
+
actual: Readonly<{ lines: number; bytes: number }>;
|
|
26
|
+
readCount: number;
|
|
27
|
+
firstReadAt: string;
|
|
28
|
+
lastReadAt: string;
|
|
29
|
+
}) {
|
|
30
|
+
this.contentHash = opts.contentHash;
|
|
31
|
+
this.outline = opts.outline;
|
|
32
|
+
this.jumpTable = opts.jumpTable;
|
|
33
|
+
this.actual = opts.actual;
|
|
34
|
+
this._readCount = opts.readCount;
|
|
35
|
+
this.firstReadAt = opts.firstReadAt;
|
|
36
|
+
this._lastReadAt = opts.lastReadAt;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get readCount(): number {
|
|
40
|
+
return this._readCount;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get lastReadAt(): string {
|
|
44
|
+
return this._lastReadAt;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
isStale(currentContentHash: string): boolean {
|
|
48
|
+
return this.contentHash !== currentContentHash;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
touch(): void {
|
|
52
|
+
this._readCount++;
|
|
53
|
+
this._lastReadAt = new Date().toISOString();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type CacheResult =
|
|
58
|
+
| { hit: true; obs: Observation }
|
|
59
|
+
| { hit: false; stale: Observation | null };
|
|
60
|
+
|
|
61
|
+
export class ObservationCache {
|
|
62
|
+
private readonly entries = new Map<string, Observation>();
|
|
63
|
+
|
|
64
|
+
record(
|
|
65
|
+
filePath: string,
|
|
66
|
+
contentHash: string,
|
|
67
|
+
outline: readonly OutlineEntry[],
|
|
68
|
+
jumpTable: readonly JumpEntry[],
|
|
69
|
+
actual: Readonly<{ lines: number; bytes: number }>,
|
|
70
|
+
): void {
|
|
71
|
+
const existing = this.entries.get(filePath);
|
|
72
|
+
const now = new Date().toISOString();
|
|
73
|
+
this.entries.set(filePath, new Observation({
|
|
74
|
+
contentHash,
|
|
75
|
+
outline,
|
|
76
|
+
jumpTable,
|
|
77
|
+
actual,
|
|
78
|
+
readCount: (existing?.readCount ?? 0) + 1,
|
|
79
|
+
firstReadAt: existing?.firstReadAt ?? now,
|
|
80
|
+
lastReadAt: now,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
check(filePath: string, currentContent: string): CacheResult {
|
|
85
|
+
const obs = this.entries.get(filePath);
|
|
86
|
+
if (obs === undefined) return { hit: false, stale: null };
|
|
87
|
+
if (!obs.isStale(hashContent(currentContent))) return { hit: true, obs };
|
|
88
|
+
return { hit: false, stale: obs };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get(filePath: string): Observation | undefined {
|
|
92
|
+
return this.entries.get(filePath);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { extractOutline } from "../parser/outline.js";
|
|
2
|
+
import { detectLang } from "../parser/lang.js";
|
|
3
|
+
import type { OutlineEntry, JumpEntry } from "../parser/types.js";
|
|
4
|
+
import { hashContent } from "./cache.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Immutable snapshot of a file read. Built once from a single readFileSync,
|
|
8
|
+
* shared by all consumers (cache, policy, outline extraction) to eliminate
|
|
9
|
+
* TOCTOU races where the file changes between reads.
|
|
10
|
+
*/
|
|
11
|
+
export class CachedFile {
|
|
12
|
+
/** @internal */
|
|
13
|
+
private readonly _brand = "CachedFile" as const;
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly rawContent: string;
|
|
16
|
+
readonly hash: string;
|
|
17
|
+
readonly outline: readonly OutlineEntry[];
|
|
18
|
+
readonly jumpTable: readonly JumpEntry[];
|
|
19
|
+
readonly actual: { readonly lines: number; readonly bytes: number };
|
|
20
|
+
|
|
21
|
+
constructor(filePath: string, rawContent: string) {
|
|
22
|
+
this.path = filePath;
|
|
23
|
+
this.rawContent = rawContent;
|
|
24
|
+
this.hash = hashContent(rawContent);
|
|
25
|
+
this.actual = {
|
|
26
|
+
lines: rawContent.split("\n").length,
|
|
27
|
+
bytes: Buffer.byteLength(rawContent),
|
|
28
|
+
};
|
|
29
|
+
// Fallback to "ts" parser for unknown extensions — TS/JS parser handles
|
|
30
|
+
// .mjs, .cjs, and other JS-family files that detectLang doesn't cover.
|
|
31
|
+
const lang = detectLang(filePath) ?? "ts";
|
|
32
|
+
const result = extractOutline(rawContent, lang);
|
|
33
|
+
this.outline = result.entries;
|
|
34
|
+
this.jumpTable = result.jumpTable ?? [];
|
|
35
|
+
Object.freeze(this.actual);
|
|
36
|
+
Object.freeze(this);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// ToolContext — shared dependencies injected into every tool handler
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import type { ObservationCache } from "./cache.js";
|
|
7
|
+
import type { Metrics } from "./metrics.js";
|
|
8
|
+
import type { SessionTracker } from "../session/tracker.js";
|
|
9
|
+
import type { McpToolResult } from "./receipt.js";
|
|
10
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
11
|
+
import type { JsonCodec } from "../ports/codec.js";
|
|
12
|
+
|
|
13
|
+
import type { z } from "zod";
|
|
14
|
+
|
|
15
|
+
export type ToolHandler = (args: Record<string, unknown>) => McpToolResult | Promise<McpToolResult>;
|
|
16
|
+
|
|
17
|
+
export interface ToolDefinition {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly description: string;
|
|
20
|
+
readonly schema?: Record<string, z.ZodType>;
|
|
21
|
+
readonly policyCheck?: boolean;
|
|
22
|
+
readonly createHandler: (ctx: ToolContext) => ToolHandler;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ToolContext {
|
|
26
|
+
readonly projectRoot: string;
|
|
27
|
+
readonly graftDir: string;
|
|
28
|
+
readonly session: SessionTracker;
|
|
29
|
+
readonly cache: ObservationCache;
|
|
30
|
+
readonly metrics: Metrics;
|
|
31
|
+
readonly fs: FileSystem;
|
|
32
|
+
readonly codec: JsonCodec;
|
|
33
|
+
respond(tool: string, data: Record<string, unknown>): McpToolResult;
|
|
34
|
+
resolvePath(relative: string): string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a user-provided path against projectRoot with traversal guard.
|
|
39
|
+
* Absolute paths pass through unchanged. Relative paths that escape the
|
|
40
|
+
* project root via ".." are rejected.
|
|
41
|
+
*/
|
|
42
|
+
export function createPathResolver(projectRoot: string): (input: string) => string {
|
|
43
|
+
return (input: string): string => {
|
|
44
|
+
if (path.isAbsolute(input)) return input;
|
|
45
|
+
const resolved = path.resolve(projectRoot, input);
|
|
46
|
+
const rel = path.relative(projectRoot, resolved);
|
|
47
|
+
if (rel.startsWith("..")) {
|
|
48
|
+
throw new Error(`Path traversal blocked: ${input}`);
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
};
|
|
52
|
+
}
|