@hasna/hooks 0.2.20 → 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/README.md +46 -2
- package/bin/index.js +1205 -93
- package/dist/db/migrations/002_session_events.d.ts +10 -0
- package/dist/db/migrations/003_user_prompt_submit_event.d.ts +10 -0
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/db/remote-storage.d.ts +9 -0
- package/dist/db/schema.d.ts +4 -3
- package/dist/db/storage-sync.d.ts +57 -0
- package/dist/index.d.ts +16 -3
- package/dist/index.js +1092 -67
- package/dist/lib/installer.d.ts +26 -5
- package/dist/lib/registry.d.ts +5 -1
- package/dist/storage.d.ts +4 -0
- package/dist/storage.js +696 -0
- package/hooks/codewith-native-common.test.ts +61 -0
- package/hooks/codewith-native-common.ts +478 -0
- package/hooks/hook-agent-rules-version-check/LICENSE +191 -0
- package/hooks/hook-agent-rules-version-check/README.md +50 -0
- package/hooks/hook-agent-rules-version-check/package.json +53 -0
- package/hooks/hook-agent-rules-version-check/src/hook.test.ts +104 -0
- package/hooks/hook-agent-rules-version-check/src/hook.ts +215 -0
- package/hooks/hook-agent-rules-version-check/tsconfig.json +25 -0
- package/hooks/hook-announce-start/README.md +7 -5
- package/hooks/hook-announce-start/package.json +2 -2
- package/hooks/hook-announce-start/src/hook.ts +33 -14
- package/hooks/hook-fleet-blockers-gate/LICENSE +191 -0
- package/hooks/hook-fleet-blockers-gate/README.md +39 -0
- package/hooks/hook-fleet-blockers-gate/package.json +52 -0
- package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +106 -0
- package/hooks/hook-fleet-blockers-gate/src/hook.ts +242 -0
- package/hooks/hook-fleet-blockers-gate/tsconfig.json +25 -0
- package/hooks/hook-fleet-catchup/LICENSE +191 -0
- package/hooks/hook-fleet-catchup/README.md +40 -0
- package/hooks/hook-fleet-catchup/package.json +52 -0
- package/hooks/hook-fleet-catchup/src/hook.test.ts +156 -0
- package/hooks/hook-fleet-catchup/src/hook.ts +283 -0
- package/hooks/hook-fleet-catchup/tsconfig.json +25 -0
- package/hooks/hook-knowledge-context/README.md +53 -0
- package/hooks/hook-knowledge-context/package.json +52 -0
- package/hooks/hook-knowledge-context/src/hook.test.ts +168 -0
- package/hooks/hook-knowledge-context/src/hook.ts +355 -0
- package/hooks/hook-knowledge-context/tsconfig.json +25 -0
- package/hooks/pre-bash/README.md +17 -0
- package/hooks/pre-bash/package.json +12 -0
- package/hooks/pre-bash/src/hook.ts +103 -0
- package/hooks/prompt-guard/README.md +17 -0
- package/hooks/prompt-guard/package.json +12 -0
- package/hooks/prompt-guard/src/hook.ts +76 -0
- package/hooks/session-start/README.md +17 -0
- package/hooks/session-start/package.json +12 -0
- package/hooks/session-start/src/hook.ts +150 -0
- package/hooks/stop-sync/README.md +17 -0
- package/hooks/stop-sync/package.json +12 -0
- package/hooks/stop-sync/src/hook.ts +69 -0
- package/hooks/worktree-guard/README.md +17 -0
- package/hooks/worktree-guard/package.json +12 -0
- package/hooks/worktree-guard/src/hook.ts +88 -0
- package/package.json +14 -6
- package/dist/cli/components/App.d.ts +0 -7
- package/dist/cli/components/CategorySelect.d.ts +0 -7
- package/dist/cli/components/DataTable.d.ts +0 -11
- package/dist/cli/components/Header.d.ts +0 -7
- package/dist/cli/components/HookSelect.d.ts +0 -11
- package/dist/cli/components/InstallProgress.d.ts +0 -9
- package/dist/cli/components/SearchView.d.ts +0 -9
- package/dist/cli/index.d.ts +0 -2
- package/dist/lib/db-writer.d.ts +0 -9
- package/dist/mcp/http.d.ts +0 -16
- package/dist/mcp/server.d.ts +0 -17
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildHookOutput,
|
|
4
|
+
buildKnowledgeArgs,
|
|
5
|
+
buildQuery,
|
|
6
|
+
extractContextText,
|
|
7
|
+
getConfig,
|
|
8
|
+
redactSecrets,
|
|
9
|
+
sanitizeQuery,
|
|
10
|
+
type KnowledgeContextConfig,
|
|
11
|
+
type KnowledgeExecutor,
|
|
12
|
+
} from "./hook";
|
|
13
|
+
|
|
14
|
+
const baseConfig: KnowledgeContextConfig = {
|
|
15
|
+
command: "knowledge",
|
|
16
|
+
timeoutMs: 1500,
|
|
17
|
+
maxItems: 6,
|
|
18
|
+
maxTokens: 1200,
|
|
19
|
+
maxQueryChars: 1200,
|
|
20
|
+
maxOutputChars: 8000,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("hook-knowledge-context", () => {
|
|
24
|
+
test("builds deterministic knowledge context pack args without semantic, web, or generation flags", () => {
|
|
25
|
+
const args = buildKnowledgeArgs("repo hook context", baseConfig);
|
|
26
|
+
|
|
27
|
+
expect(args).toEqual([
|
|
28
|
+
"context",
|
|
29
|
+
"pack",
|
|
30
|
+
"repo hook context",
|
|
31
|
+
"--from",
|
|
32
|
+
"search",
|
|
33
|
+
"--max-items",
|
|
34
|
+
"6",
|
|
35
|
+
"--max-tokens",
|
|
36
|
+
"1200",
|
|
37
|
+
"--json",
|
|
38
|
+
]);
|
|
39
|
+
expect(args).not.toContain("--semantic");
|
|
40
|
+
expect(args).not.toContain("--generate");
|
|
41
|
+
expect(args).not.toContain("web");
|
|
42
|
+
expect(args).not.toContain("ask");
|
|
43
|
+
expect(args).not.toContain("build");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("emits Codewith additionalContext for SessionStart", async () => {
|
|
47
|
+
const executor: KnowledgeExecutor = async () => ({
|
|
48
|
+
ok: true,
|
|
49
|
+
stdout: JSON.stringify({ context: "Use hooks.json for Codewith lifecycle hooks." }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const output = await buildHookOutput(
|
|
53
|
+
{
|
|
54
|
+
hook_event_name: "SessionStart",
|
|
55
|
+
cwd: "/repo",
|
|
56
|
+
source: "startup",
|
|
57
|
+
model: "gpt-test",
|
|
58
|
+
},
|
|
59
|
+
executor
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(output.continue).toBe(true);
|
|
63
|
+
expect(output.hookSpecificOutput?.hookEventName).toBe("SessionStart");
|
|
64
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("knowledge context pack --from search");
|
|
65
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("Use hooks.json");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("emits event-matched additionalContext for UserPromptSubmit and redacts query secrets", async () => {
|
|
69
|
+
let capturedQuery = "";
|
|
70
|
+
const executor: KnowledgeExecutor = async (query) => {
|
|
71
|
+
capturedQuery = query;
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
stdout: JSON.stringify({
|
|
75
|
+
items: [{ title: "Decision", text: "Prefer deterministic context packs. secret=demo" }],
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const output = await buildHookOutput(
|
|
81
|
+
{
|
|
82
|
+
hook_event_name: "UserPromptSubmit",
|
|
83
|
+
cwd: "/repo",
|
|
84
|
+
prompt: "implement this with token=demo",
|
|
85
|
+
},
|
|
86
|
+
executor
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(output.hookSpecificOutput?.hookEventName).toBe("UserPromptSubmit");
|
|
90
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("Prefer deterministic context packs");
|
|
91
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("secret=<redacted>");
|
|
92
|
+
expect(output.hookSpecificOutput?.additionalContext).not.toContain("secret=demo");
|
|
93
|
+
expect(capturedQuery).toContain("token=<redacted>");
|
|
94
|
+
expect(capturedQuery).not.toContain("token=demo");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("emits event-matched additionalContext for SubagentStart", async () => {
|
|
98
|
+
const executor: KnowledgeExecutor = async () => ({
|
|
99
|
+
ok: true,
|
|
100
|
+
stdout: JSON.stringify({ data: { markdown: "Subagents inherit parent scope and should stay bounded." } }),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const output = await buildHookOutput(
|
|
104
|
+
{
|
|
105
|
+
hook_event_name: "SubagentStart",
|
|
106
|
+
cwd: "/repo",
|
|
107
|
+
agent_type: "verifier",
|
|
108
|
+
turn_id: "turn-1",
|
|
109
|
+
},
|
|
110
|
+
executor
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(output.hookSpecificOutput?.hookEventName).toBe("SubagentStart");
|
|
114
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("Subagents inherit parent scope");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("fails open without context on executor error, timeout, or invalid JSON", async () => {
|
|
118
|
+
const failed = await buildHookOutput({ hook_event_name: "SessionStart", cwd: "/repo" }, async () => ({ ok: false }));
|
|
119
|
+
const timedOut = await buildHookOutput(
|
|
120
|
+
{ hook_event_name: "SessionStart", cwd: "/repo" },
|
|
121
|
+
async () => ({ ok: false, timedOut: true })
|
|
122
|
+
);
|
|
123
|
+
const invalidJson = await buildHookOutput(
|
|
124
|
+
{ hook_event_name: "SessionStart", cwd: "/repo" },
|
|
125
|
+
async () => ({ ok: true, stdout: "not json" })
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(failed).toEqual({ continue: true });
|
|
129
|
+
expect(timedOut).toEqual({ continue: true });
|
|
130
|
+
expect(invalidJson).toEqual({ continue: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("fails open for disabled or unsupported events", async () => {
|
|
134
|
+
const executor: KnowledgeExecutor = async () => {
|
|
135
|
+
throw new Error("executor should not run");
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
expect(
|
|
139
|
+
await buildHookOutput({ hook_event_name: "PostToolUse", cwd: "/repo" }, executor)
|
|
140
|
+
).toEqual({ continue: true });
|
|
141
|
+
expect(
|
|
142
|
+
await buildHookOutput({ hook_event_name: "SessionStart", cwd: "/repo" }, executor, {
|
|
143
|
+
HOOKS_KNOWLEDGE_CONTEXT_DISABLE: "1",
|
|
144
|
+
})
|
|
145
|
+
).toEqual({ continue: true });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("sanitizes and bounds queries", () => {
|
|
149
|
+
expect(redactSecrets("password=hunter2 token=demo")).toBe(
|
|
150
|
+
"password=<redacted> token=<redacted>"
|
|
151
|
+
);
|
|
152
|
+
expect(sanitizeQuery("a\nb\tc", 100)).toBe("a b c");
|
|
153
|
+
expect(sanitizeQuery("word ".repeat(200), 20).length).toBeLessThanOrEqual(20);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("builds event-specific queries and extracts common context pack shapes", () => {
|
|
157
|
+
const config = getConfig({});
|
|
158
|
+
|
|
159
|
+
expect(buildQuery({ hook_event_name: "SessionStart", cwd: "/repo", source: "resume" }, config)).toContain(
|
|
160
|
+
"session start"
|
|
161
|
+
);
|
|
162
|
+
expect(buildQuery({ hook_event_name: "SubagentStart", cwd: "/repo", agent_type: "reviewer" }, config)).toContain(
|
|
163
|
+
"subagent start"
|
|
164
|
+
);
|
|
165
|
+
expect(extractContextText({ pack: { context: "direct context" } })).toBe("direct context");
|
|
166
|
+
expect(extractContextText([{ title: "One", text: "Body", uri: "knowledge://item/one" }])).toContain("Body");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Codewith Hook: knowledge-context
|
|
5
|
+
*
|
|
6
|
+
* Deterministically injects bounded Knowledge context into Codewith lifecycle
|
|
7
|
+
* events. This hook never calls LLM providers, semantic search, web search, or
|
|
8
|
+
* raw stores directly; it delegates to `knowledge context pack --from search`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "fs";
|
|
12
|
+
import { spawn } from "child_process";
|
|
13
|
+
|
|
14
|
+
export type KnowledgeContextEvent = "SessionStart" | "UserPromptSubmit" | "SubagentStart";
|
|
15
|
+
|
|
16
|
+
interface HookInput {
|
|
17
|
+
session_id?: string;
|
|
18
|
+
cwd?: string;
|
|
19
|
+
hook_event_name?: string;
|
|
20
|
+
model?: string;
|
|
21
|
+
permission_mode?: string;
|
|
22
|
+
source?: string;
|
|
23
|
+
prompt?: string;
|
|
24
|
+
user_prompt?: string;
|
|
25
|
+
agent_id?: string;
|
|
26
|
+
agent_type?: string;
|
|
27
|
+
turn_id?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface HookOutput {
|
|
31
|
+
continue: boolean;
|
|
32
|
+
hookSpecificOutput?: {
|
|
33
|
+
hookEventName: KnowledgeContextEvent;
|
|
34
|
+
additionalContext: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface KnowledgeContextConfig {
|
|
39
|
+
command: string;
|
|
40
|
+
timeoutMs: number;
|
|
41
|
+
maxItems: number;
|
|
42
|
+
maxTokens: number;
|
|
43
|
+
maxQueryChars: number;
|
|
44
|
+
maxOutputChars: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface KnowledgeExecResult {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
stdout?: string;
|
|
50
|
+
timedOut?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type KnowledgeExecutor = (
|
|
54
|
+
query: string,
|
|
55
|
+
config: KnowledgeContextConfig
|
|
56
|
+
) => Promise<KnowledgeExecResult>;
|
|
57
|
+
|
|
58
|
+
const DEFAULT_TIMEOUT_MS = 1500;
|
|
59
|
+
const DEFAULT_MAX_ITEMS = 6;
|
|
60
|
+
const DEFAULT_MAX_TOKENS = 1200;
|
|
61
|
+
const DEFAULT_MAX_QUERY_CHARS = 1200;
|
|
62
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 8000;
|
|
63
|
+
const SUPPORTED_EVENTS = new Set<KnowledgeContextEvent>([
|
|
64
|
+
"SessionStart",
|
|
65
|
+
"UserPromptSubmit",
|
|
66
|
+
"SubagentStart",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
function readStdinJson(): HookInput | null {
|
|
70
|
+
try {
|
|
71
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
72
|
+
if (!input) return null;
|
|
73
|
+
return JSON.parse(input);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function respond(output: HookOutput): void {
|
|
80
|
+
console.log(JSON.stringify(output));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function boundedNumber(raw: string | undefined, fallback: number, min: number, max: number): number {
|
|
84
|
+
const value = Number(raw);
|
|
85
|
+
if (!Number.isFinite(value)) return fallback;
|
|
86
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function commandFromEnv(raw: string | undefined): string {
|
|
90
|
+
if (raw && /^[^\s\0]+$/.test(raw)) return raw;
|
|
91
|
+
return "knowledge";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getConfig(env: NodeJS.ProcessEnv = process.env): KnowledgeContextConfig {
|
|
95
|
+
return {
|
|
96
|
+
command: commandFromEnv(env.HOOKS_KNOWLEDGE_COMMAND),
|
|
97
|
+
timeoutMs: boundedNumber(env.HOOKS_KNOWLEDGE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 100, 10_000),
|
|
98
|
+
maxItems: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_ITEMS, DEFAULT_MAX_ITEMS, 1, 20),
|
|
99
|
+
maxTokens: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_TOKENS, DEFAULT_MAX_TOKENS, 100, 8_000),
|
|
100
|
+
maxQueryChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_QUERY_CHARS, DEFAULT_MAX_QUERY_CHARS, 80, 4_000),
|
|
101
|
+
maxOutputChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS, DEFAULT_MAX_OUTPUT_CHARS, 500, 20_000),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isKnowledgeContextEvent(value: string | undefined): value is KnowledgeContextEvent {
|
|
106
|
+
return SUPPORTED_EVENTS.has(value as KnowledgeContextEvent);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stringField(input: HookInput, key: keyof HookInput): string {
|
|
110
|
+
const value = input[key];
|
|
111
|
+
return typeof value === "string" ? value : "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function redactSecrets(text: string): string {
|
|
115
|
+
let redacted = text;
|
|
116
|
+
redacted = redacted.replace(
|
|
117
|
+
/\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*[^,\s]+/gi,
|
|
118
|
+
(_match, key: string) => `${key}=<redacted>`
|
|
119
|
+
);
|
|
120
|
+
redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, "<redacted>");
|
|
121
|
+
redacted = redacted.replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "<redacted>");
|
|
122
|
+
redacted = redacted.replace(/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g, "<redacted>");
|
|
123
|
+
redacted = redacted.replace(/\bAKIA[0-9A-Z]{16}\b/g, "<redacted>");
|
|
124
|
+
redacted = redacted.replace(/\bAIza[0-9A-Za-z_-]{20,}\b/g, "<redacted>");
|
|
125
|
+
redacted = redacted.replace(/\b[A-Za-z0-9+/]{48,}={0,2}\b/g, "<redacted>");
|
|
126
|
+
return redacted;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function sanitizeQuery(text: string, maxChars: number): string {
|
|
130
|
+
const normalized = text
|
|
131
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
132
|
+
.replace(/\s+/g, " ")
|
|
133
|
+
.trim();
|
|
134
|
+
const redacted = redactSecrets(normalized);
|
|
135
|
+
return redacted.length > maxChars ? redacted.slice(0, maxChars).trim() : redacted;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function buildQuery(input: HookInput, config: KnowledgeContextConfig): string | null {
|
|
139
|
+
const cwd = stringField(input, "cwd");
|
|
140
|
+
const event = stringField(input, "hook_event_name");
|
|
141
|
+
|
|
142
|
+
if (event === "UserPromptSubmit") {
|
|
143
|
+
const prompt = stringField(input, "prompt") || stringField(input, "user_prompt");
|
|
144
|
+
return sanitizeQuery(`Codewith user prompt context. cwd: ${cwd}. prompt: ${prompt}`, config.maxQueryChars);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (event === "SubagentStart") {
|
|
148
|
+
const agentType = stringField(input, "agent_type") || "subagent";
|
|
149
|
+
const turnId = stringField(input, "turn_id");
|
|
150
|
+
return sanitizeQuery(
|
|
151
|
+
`Codewith subagent start context. cwd: ${cwd}. agent type: ${agentType}. turn: ${turnId}`,
|
|
152
|
+
config.maxQueryChars
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (event === "SessionStart") {
|
|
157
|
+
const source = stringField(input, "source") || "startup";
|
|
158
|
+
const model = stringField(input, "model");
|
|
159
|
+
return sanitizeQuery(`Codewith session start context. cwd: ${cwd}. source: ${source}. model: ${model}`, config.maxQueryChars);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function buildKnowledgeArgs(query: string, config: KnowledgeContextConfig): string[] {
|
|
166
|
+
return [
|
|
167
|
+
"context",
|
|
168
|
+
"pack",
|
|
169
|
+
query,
|
|
170
|
+
"--from",
|
|
171
|
+
"search",
|
|
172
|
+
"--max-items",
|
|
173
|
+
String(config.maxItems),
|
|
174
|
+
"--max-tokens",
|
|
175
|
+
String(config.maxTokens),
|
|
176
|
+
"--json",
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function spawnKnowledgeContextPack(query: string, config: KnowledgeContextConfig): Promise<KnowledgeExecResult> {
|
|
181
|
+
const args = buildKnowledgeArgs(query, config);
|
|
182
|
+
|
|
183
|
+
return new Promise((resolve) => {
|
|
184
|
+
let stdout = "";
|
|
185
|
+
let settled = false;
|
|
186
|
+
let timedOut = false;
|
|
187
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
188
|
+
|
|
189
|
+
const settle = (result: KnowledgeExecResult): void => {
|
|
190
|
+
if (settled) return;
|
|
191
|
+
settled = true;
|
|
192
|
+
if (timer) clearTimeout(timer);
|
|
193
|
+
resolve(result);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const child = spawn(config.command, args, {
|
|
197
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
198
|
+
env: process.env,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
timer = setTimeout(() => {
|
|
202
|
+
timedOut = true;
|
|
203
|
+
try {
|
|
204
|
+
child.kill("SIGTERM");
|
|
205
|
+
} catch {
|
|
206
|
+
// ignore kill errors; close/error will settle
|
|
207
|
+
}
|
|
208
|
+
}, config.timeoutMs);
|
|
209
|
+
|
|
210
|
+
child.stdout.setEncoding("utf-8");
|
|
211
|
+
child.stdout.on("data", (chunk: string) => {
|
|
212
|
+
stdout += chunk;
|
|
213
|
+
if (stdout.length > config.maxOutputChars * 2) {
|
|
214
|
+
stdout = stdout.slice(0, config.maxOutputChars * 2);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
child.on("error", () => settle({ ok: false, timedOut }));
|
|
218
|
+
child.on("close", (code) => settle({ ok: code === 0 && !timedOut, stdout, timedOut }));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function firstString(obj: Record<string, unknown>, keys: string[]): string | null {
|
|
223
|
+
for (const key of keys) {
|
|
224
|
+
const value = obj[key];
|
|
225
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function firstArray(obj: Record<string, unknown>, keys: string[]): unknown[] | null {
|
|
231
|
+
for (const key of keys) {
|
|
232
|
+
const value = obj[key];
|
|
233
|
+
if (Array.isArray(value)) return value;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function truncate(text: string, max: number): string {
|
|
239
|
+
if (text.length <= max) return text;
|
|
240
|
+
if (max <= 3) return text.slice(0, max);
|
|
241
|
+
return `${text.slice(0, max - 3)}...`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function formatPackItems(items: unknown[]): string | null {
|
|
245
|
+
const lines: string[] = [];
|
|
246
|
+
|
|
247
|
+
for (const [index, item] of items.entries()) {
|
|
248
|
+
if (typeof item === "string" && item.trim()) {
|
|
249
|
+
lines.push(`- ${truncate(item.trim(), 900)}`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!item || typeof item !== "object") continue;
|
|
253
|
+
|
|
254
|
+
const obj = item as Record<string, unknown>;
|
|
255
|
+
const title = firstString(obj, ["title", "name", "id", "ref", "uri"]) || `item ${index + 1}`;
|
|
256
|
+
const body = firstString(obj, ["summary", "text", "content", "snippet", "preview", "body"]);
|
|
257
|
+
const source = firstString(obj, ["source", "uri", "ref", "url", "citation"]);
|
|
258
|
+
if (!body && !source) continue;
|
|
259
|
+
const suffix = source ? ` (source: ${truncate(source, 180)})` : "";
|
|
260
|
+
lines.push(`- ${truncate(title, 160)}${suffix}${body ? `: ${truncate(body, 900)}` : ""}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return lines.length > 0 ? lines.join("\n") : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function extractContextText(pack: unknown): string | null {
|
|
267
|
+
if (typeof pack === "string") {
|
|
268
|
+
return pack.trim() || null;
|
|
269
|
+
}
|
|
270
|
+
if (Array.isArray(pack)) {
|
|
271
|
+
return formatPackItems(pack);
|
|
272
|
+
}
|
|
273
|
+
if (!pack || typeof pack !== "object") {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const obj = pack as Record<string, unknown>;
|
|
278
|
+
const direct = firstString(obj, ["additionalContext", "context", "markdown", "content", "text", "summary"]);
|
|
279
|
+
if (direct) return direct;
|
|
280
|
+
|
|
281
|
+
for (const key of ["pack", "contextPack", "context_pack", "data", "result"]) {
|
|
282
|
+
const nested = obj[key];
|
|
283
|
+
const text = extractContextText(nested);
|
|
284
|
+
if (text) return text;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const items = firstArray(obj, ["items", "results", "sources", "citations"]);
|
|
288
|
+
return items ? formatPackItems(items) : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function formatAdditionalContext(
|
|
292
|
+
event: KnowledgeContextEvent,
|
|
293
|
+
context: string,
|
|
294
|
+
config: KnowledgeContextConfig
|
|
295
|
+
): string {
|
|
296
|
+
const header = `[hook-knowledge-context] Deterministic Knowledge context (${event}; knowledge context pack --from search):`;
|
|
297
|
+
const safeContext = redactSecrets(context.trim());
|
|
298
|
+
return `${header}\n\n${truncate(safeContext, Math.max(0, config.maxOutputChars - header.length - 2))}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function buildHookOutput(
|
|
302
|
+
input: HookInput | null,
|
|
303
|
+
executor: KnowledgeExecutor = spawnKnowledgeContextPack,
|
|
304
|
+
env: NodeJS.ProcessEnv = process.env
|
|
305
|
+
): Promise<HookOutput> {
|
|
306
|
+
if (env.HOOKS_KNOWLEDGE_CONTEXT_DISABLE === "1") {
|
|
307
|
+
return { continue: true };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!input) {
|
|
311
|
+
return { continue: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const event = input.hook_event_name;
|
|
315
|
+
if (!isKnowledgeContextEvent(event)) {
|
|
316
|
+
return { continue: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const config = getConfig(env);
|
|
320
|
+
const query = buildQuery(input, config);
|
|
321
|
+
if (!query) {
|
|
322
|
+
return { continue: true };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const result = await executor(query, config);
|
|
327
|
+
if (!result.ok || !result.stdout) {
|
|
328
|
+
return { continue: true };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const parsed = JSON.parse(result.stdout);
|
|
332
|
+
const context = extractContextText(parsed);
|
|
333
|
+
if (!context) {
|
|
334
|
+
return { continue: true };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
continue: true,
|
|
339
|
+
hookSpecificOutput: {
|
|
340
|
+
hookEventName: event,
|
|
341
|
+
additionalContext: formatAdditionalContext(event, context, config),
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
} catch {
|
|
345
|
+
return { continue: true };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function run(): Promise<void> {
|
|
350
|
+
respond(await buildHookOutput(readStdinJson()));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (import.meta.main) {
|
|
354
|
+
void run();
|
|
355
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ESNext"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"allowImportingTsExtensions": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"declarationMap": true,
|
|
19
|
+
"outDir": "./dist",
|
|
20
|
+
"rootDir": "./src",
|
|
21
|
+
"types": ["bun-types", "node"]
|
|
22
|
+
},
|
|
23
|
+
"include": ["src/**/*"],
|
|
24
|
+
"exclude": ["node_modules", "dist"]
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# pre-bash
|
|
2
|
+
|
|
3
|
+
Codewith-native hook installed as `hooks run pre-bash`.
|
|
4
|
+
|
|
5
|
+
This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail open with concise warnings. Security gates only fail closed when a guarded commit/push scan runs successfully and finds possible secrets.
|
|
6
|
+
|
|
7
|
+
## Install for Codewith
|
|
8
|
+
|
|
9
|
+
Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
hooks install pre-bash --target codewith
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
Apache-2.0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pre-bash",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Codewith-native Pre Bash hook for @hasna/hooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/hook.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"author": "Hasna",
|
|
11
|
+
"license": "Apache-2.0"
|
|
12
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
commandExists,
|
|
5
|
+
gitCommandInfo,
|
|
6
|
+
getCommand,
|
|
7
|
+
isBashPreToolUse,
|
|
8
|
+
isRiskyOperation,
|
|
9
|
+
readCache,
|
|
10
|
+
readInput,
|
|
11
|
+
redactGitleaksOutput,
|
|
12
|
+
respond,
|
|
13
|
+
runCommand,
|
|
14
|
+
warn,
|
|
15
|
+
type CodewithHookInput,
|
|
16
|
+
} from "../../codewith-native-common";
|
|
17
|
+
|
|
18
|
+
interface SessionDigest {
|
|
19
|
+
context: string;
|
|
20
|
+
warnings: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function runStagedSecretsScan(cwd: string): Promise<{ block: boolean; reason?: string; warning?: string }> {
|
|
24
|
+
if (!commandExists("gitleaks")) {
|
|
25
|
+
return { block: false, warning: "gitleaks unavailable; staged secrets scan skipped fail-open." };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = await runCommand([
|
|
29
|
+
"gitleaks", "git", "--staged", "--no-banner", "--redact", "--report-format", "json", "--report-path", "-", ".",
|
|
30
|
+
], { cwd, timeoutMs: 15_000 });
|
|
31
|
+
|
|
32
|
+
if (result.exitCode === 0) return { block: false };
|
|
33
|
+
if (result.exitCode === 1) {
|
|
34
|
+
return { block: true, reason: redactGitleaksOutput(result.stdout, result.stderr) };
|
|
35
|
+
}
|
|
36
|
+
return { block: false, warning: `gitleaks scan did not complete cleanly (exit ${result.exitCode ?? "unknown"}${result.timedOut ? ", timed out" : ""}); fail-open.` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function riskyCommsBlock(): string | null {
|
|
40
|
+
const cached = readCache<SessionDigest>("session-start-digest", 5 * 60_000);
|
|
41
|
+
if (!cached) return null;
|
|
42
|
+
const context = cached.context || "";
|
|
43
|
+
if (/\[FREEZE\]/i.test(context)) return "Cached announcements contain a [FREEZE]. Re-check conversations before running risky operations.";
|
|
44
|
+
if (/Unread blockers JSON:\n\s*\[(?!\s*\])/i.test(context)) return "Cached conversations blockers are non-empty. Resolve or verify blockers before risky operations.";
|
|
45
|
+
if (/"message_ids"\s*:\s*\[(?!\s*\])/i.test(context) && /blockers/i.test(context)) return "Cached blocker digest appears non-empty. Re-check conversations before risky operations.";
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function evaluate(input: CodewithHookInput): Promise<{ output: Record<string, unknown>; warnings: string[] }> {
|
|
50
|
+
const warnings: string[] = [];
|
|
51
|
+
if (!isBashPreToolUse(input)) return { output: { continue: true }, warnings };
|
|
52
|
+
const command = getCommand(input);
|
|
53
|
+
if (!command) return { output: { continue: true }, warnings };
|
|
54
|
+
const cwd = input.cwd || process.cwd();
|
|
55
|
+
const gitInfo = gitCommandInfo(command, cwd);
|
|
56
|
+
|
|
57
|
+
if (gitInfo) {
|
|
58
|
+
const scan = await runStagedSecretsScan(gitInfo.targetCwd);
|
|
59
|
+
if (scan.warning) warnings.push(scan.warning);
|
|
60
|
+
if (scan.block) {
|
|
61
|
+
return { output: { decision: "block", reason: scan.reason }, warnings };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (isRiskyOperation(command)) {
|
|
66
|
+
const reason = riskyCommsBlock();
|
|
67
|
+
if (reason) return { output: { decision: "block", reason }, warnings };
|
|
68
|
+
if (!readCache<SessionDigest>("session-start-digest", 5 * 60_000)) {
|
|
69
|
+
warnings.push("No fresh session-start comms cache; risky operation allowed fail-open, but manually re-check blockers/announcements first.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (warnings.length > 0) {
|
|
74
|
+
return {
|
|
75
|
+
output: {
|
|
76
|
+
continue: true,
|
|
77
|
+
hookSpecificOutput: {
|
|
78
|
+
hookEventName: "PreToolUse",
|
|
79
|
+
additionalContext: warnings.map((w) => `[pre-bash] ${w}`).join("\n"),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
warnings,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { output: { continue: true }, warnings };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function run(): Promise<void> {
|
|
90
|
+
const input = readInput();
|
|
91
|
+
try {
|
|
92
|
+
const { output, warnings } = await evaluate(input);
|
|
93
|
+
for (const message of warnings) warn(message);
|
|
94
|
+
respond(output);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
warn(`pre-bash failed open: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
+
respond({ continue: true });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (import.meta.main) {
|
|
102
|
+
await run();
|
|
103
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# prompt-guard
|
|
2
|
+
|
|
3
|
+
Codewith-native hook installed as `hooks run prompt-guard`.
|
|
4
|
+
|
|
5
|
+
This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail open with concise warnings. Security gates only fail closed when a guarded commit/push scan runs successfully and finds possible secrets.
|
|
6
|
+
|
|
7
|
+
## Install for Codewith
|
|
8
|
+
|
|
9
|
+
Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
hooks install prompt-guard --target codewith
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
Apache-2.0
|