haechi 0.3.2
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/LICENSE +154 -0
- package/README.md +102 -0
- package/SECURITY.md +31 -0
- package/docs/README.md +35 -0
- package/docs/current/api-stability.ko.md +48 -0
- package/docs/current/api-stability.md +48 -0
- package/docs/current/expert-gap-review-ai-llm-mcp-encryption.ko.md +107 -0
- package/docs/current/expert-gap-review-ai-llm-mcp-encryption.md +107 -0
- package/docs/current/global-privacy-compliance-review.ko.md +110 -0
- package/docs/current/global-privacy-compliance-review.md +110 -0
- package/docs/current/initial-plan-ai-llm-mcp-encryption.ko.md +214 -0
- package/docs/current/initial-plan-ai-llm-mcp-encryption.md +214 -0
- package/docs/current/mvp-0.1-implementation-scope.ko.md +79 -0
- package/docs/current/mvp-0.1-implementation-scope.md +79 -0
- package/docs/current/open-source-modular-architecture.ko.md +387 -0
- package/docs/current/open-source-modular-architecture.md +387 -0
- package/docs/current/prd-ai-llm-mcp-encryption.ko.md +260 -0
- package/docs/current/prd-ai-llm-mcp-encryption.md +262 -0
- package/docs/current/privacy-filtering-policy-draft.ko.md +307 -0
- package/docs/current/privacy-filtering-policy-draft.md +307 -0
- package/docs/current/release-0.2-implementation-scope.ko.md +46 -0
- package/docs/current/release-0.2-implementation-scope.md +46 -0
- package/docs/current/release-0.3-implementation-scope.ko.md +86 -0
- package/docs/current/release-0.3-implementation-scope.md +86 -0
- package/docs/current/release-0.3.2-hardening-scope.ko.md +64 -0
- package/docs/current/release-0.3.2-hardening-scope.md +64 -0
- package/docs/current/release-0.4-implementation-scope.ko.md +121 -0
- package/docs/current/release-0.4-implementation-scope.md +121 -0
- package/docs/current/release-process.ko.md +48 -0
- package/docs/current/release-process.md +48 -0
- package/docs/current/risk-register-release-gate.ko.md +154 -0
- package/docs/current/risk-register-release-gate.md +154 -0
- package/docs/current/shared-responsibility.ko.md +38 -0
- package/docs/current/shared-responsibility.md +38 -0
- package/docs/current/threat-model.ko.md +68 -0
- package/docs/current/threat-model.md +68 -0
- package/examples/llm-prompt-filtering/input.json +13 -0
- package/examples/plugins/custom-filter.plugin.json +29 -0
- package/haechi.config.example.json +70 -0
- package/package.json +74 -0
- package/packages/audit/index.mjs +262 -0
- package/packages/cli/bin/haechi.mjs +341 -0
- package/packages/cli/runtime.mjs +287 -0
- package/packages/core/index.mjs +309 -0
- package/packages/crypto/index.mjs +142 -0
- package/packages/filter/index.mjs +189 -0
- package/packages/mcp-stdio/index.mjs +105 -0
- package/packages/plugin/index.mjs +83 -0
- package/packages/policy/index.mjs +165 -0
- package/packages/policy-bundle/index.mjs +91 -0
- package/packages/privacy-profiles/index.mjs +92 -0
- package/packages/protocol-adapters/index.mjs +111 -0
- package/packages/proxy/index.mjs +534 -0
- package/packages/token-vault/index.mjs +262 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
const ALG = "AES-256-GCM";
|
|
6
|
+
|
|
7
|
+
export function createLocalCryptoProvider({ keyFile }) {
|
|
8
|
+
if (!keyFile) {
|
|
9
|
+
throw new Error("Local crypto provider requires keyFile");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let cachedKeys = null;
|
|
13
|
+
|
|
14
|
+
async function loadKeys() {
|
|
15
|
+
if (cachedKeys) {
|
|
16
|
+
return cachedKeys;
|
|
17
|
+
}
|
|
18
|
+
const raw = JSON.parse(await readFile(keyFile, "utf8"));
|
|
19
|
+
if (!raw.keys?.length) {
|
|
20
|
+
throw new Error(`No keys found in ${keyFile}`);
|
|
21
|
+
}
|
|
22
|
+
const byKid = new Map();
|
|
23
|
+
for (const entry of raw.keys) {
|
|
24
|
+
const key = Buffer.from(entry.k, "base64url");
|
|
25
|
+
if (key.length !== 32) {
|
|
26
|
+
throw new Error("AES-256-GCM local key must be 32 bytes");
|
|
27
|
+
}
|
|
28
|
+
byKid.set(entry.kid, { kid: entry.kid, key });
|
|
29
|
+
}
|
|
30
|
+
const activeEntry = raw.keys.find((key) => key.status === "active") ?? raw.keys[0];
|
|
31
|
+
cachedKeys = {
|
|
32
|
+
active: byKid.get(activeEntry.kid),
|
|
33
|
+
byKid
|
|
34
|
+
};
|
|
35
|
+
return cachedKeys;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: "haechi.crypto.local-aes-gcm",
|
|
40
|
+
version: "0.1.0",
|
|
41
|
+
capabilities: {
|
|
42
|
+
readsPlaintext: true,
|
|
43
|
+
networkEgress: false
|
|
44
|
+
},
|
|
45
|
+
async encrypt({ plaintext, aad }) {
|
|
46
|
+
const { active: { kid, key } } = await loadKeys();
|
|
47
|
+
const iv = randomBytes(12);
|
|
48
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
49
|
+
const aadBytes = Buffer.from(canonicalize(aad), "utf8");
|
|
50
|
+
cipher.setAAD(aadBytes);
|
|
51
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
52
|
+
const tag = cipher.getAuthTag();
|
|
53
|
+
return {
|
|
54
|
+
v: 1,
|
|
55
|
+
alg: ALG,
|
|
56
|
+
kid,
|
|
57
|
+
iv: iv.toString("base64url"),
|
|
58
|
+
ct: ciphertext.toString("base64url"),
|
|
59
|
+
tag: tag.toString("base64url"),
|
|
60
|
+
aadHash: sha256(aadBytes)
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
async decrypt({ envelope, aad }) {
|
|
64
|
+
const { active, byKid } = await loadKeys();
|
|
65
|
+
if (envelope.alg && envelope.alg !== ALG) {
|
|
66
|
+
throw new Error(`Unsupported local crypto algorithm: ${envelope.alg}`);
|
|
67
|
+
}
|
|
68
|
+
const selected = envelope.kid ? byKid.get(envelope.kid) : active;
|
|
69
|
+
if (!selected) {
|
|
70
|
+
throw new Error(`Unknown key id in envelope: ${envelope.kid}`);
|
|
71
|
+
}
|
|
72
|
+
const { key } = selected;
|
|
73
|
+
const aadBytes = Buffer.from(canonicalize(aad), "utf8");
|
|
74
|
+
if (envelope.aadHash && envelope.aadHash !== sha256(aadBytes)) {
|
|
75
|
+
throw new Error("AAD hash mismatch");
|
|
76
|
+
}
|
|
77
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.iv, "base64url"));
|
|
78
|
+
decipher.setAAD(aadBytes);
|
|
79
|
+
decipher.setAuthTag(Buffer.from(envelope.tag, "base64url"));
|
|
80
|
+
const plaintext = Buffer.concat([
|
|
81
|
+
decipher.update(Buffer.from(envelope.ct, "base64url")),
|
|
82
|
+
decipher.final()
|
|
83
|
+
]);
|
|
84
|
+
return plaintext.toString("utf8");
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function initLocalKeyFile(keyFile, { force = false } = {}) {
|
|
90
|
+
await mkdir(dirname(keyFile), { recursive: true });
|
|
91
|
+
|
|
92
|
+
let existing = null;
|
|
93
|
+
try {
|
|
94
|
+
existing = JSON.parse(await readFile(keyFile, "utf8"));
|
|
95
|
+
if (!force) {
|
|
96
|
+
return { created: false, keyFile };
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error.code !== "ENOENT") {
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Rotating with --force must not orphan existing envelopes/token vault
|
|
105
|
+
// records, so prior keys are retained as retired and stay kid-addressable.
|
|
106
|
+
const retiredKeys = (existing?.keys ?? []).map((key) => ({
|
|
107
|
+
...key,
|
|
108
|
+
status: key.status === "active" ? "retired" : key.status
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
const key = {
|
|
112
|
+
version: 1,
|
|
113
|
+
createdAt: new Date().toISOString(),
|
|
114
|
+
keys: [
|
|
115
|
+
{
|
|
116
|
+
kid: `local-${Date.now()}-${randomBytes(3).toString("hex")}`,
|
|
117
|
+
kty: "oct",
|
|
118
|
+
alg: ALG,
|
|
119
|
+
status: "active",
|
|
120
|
+
k: randomBytes(32).toString("base64url")
|
|
121
|
+
},
|
|
122
|
+
...retiredKeys
|
|
123
|
+
]
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
await writeFile(keyFile, `${JSON.stringify(key, null, 2)}\n`, { mode: 0o600 });
|
|
127
|
+
return { created: true, keyFile, rotated: retiredKeys.length > 0 };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function canonicalize(value) {
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
return `[${value.map((item) => canonicalize(item)).join(",")}]`;
|
|
133
|
+
}
|
|
134
|
+
if (value && typeof value === "object") {
|
|
135
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(",")}}`;
|
|
136
|
+
}
|
|
137
|
+
return JSON.stringify(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sha256(value) {
|
|
141
|
+
return createHash("sha256").update(value).digest("base64url");
|
|
142
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
const DEFAULT_RULES = [
|
|
2
|
+
{
|
|
3
|
+
id: "email",
|
|
4
|
+
type: "email",
|
|
5
|
+
pattern: "\\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}\\b",
|
|
6
|
+
flags: "gi",
|
|
7
|
+
confidence: 0.95
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
id: "kr-phone",
|
|
11
|
+
type: "phone",
|
|
12
|
+
pattern: "(?:\\+82[-\\s]?)?0?1[016789][-.\\s]?\\d{3,4}[-.\\s]?\\d{4}",
|
|
13
|
+
flags: "g",
|
|
14
|
+
confidence: 0.9
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "kr-rrn-like",
|
|
18
|
+
type: "kr_rrn",
|
|
19
|
+
pattern: "\\b\\d{6}[-\\s]?[1-8]\\d{6}\\b",
|
|
20
|
+
flags: "g",
|
|
21
|
+
confidence: 0.85,
|
|
22
|
+
validate: krRrnValid
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "card-like",
|
|
26
|
+
type: "card",
|
|
27
|
+
pattern: "\\b(?:\\d[ -]*?){13,19}\\b",
|
|
28
|
+
flags: "g",
|
|
29
|
+
confidence: 0.75,
|
|
30
|
+
validate: luhnValid
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "openai-like-key",
|
|
34
|
+
type: "api_key",
|
|
35
|
+
pattern: "\\b(?:sk|rk|pk)_[A-Za-z0-9_-]{24,}\\b",
|
|
36
|
+
flags: "g",
|
|
37
|
+
confidence: 0.95
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "bearer-token",
|
|
41
|
+
type: "secret",
|
|
42
|
+
pattern: "\\bBearer\\s+[A-Za-z0-9._~+/-]{16,}\\b",
|
|
43
|
+
flags: "g",
|
|
44
|
+
confidence: 0.9
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "assignment-secret",
|
|
48
|
+
type: "secret",
|
|
49
|
+
// Lookbehind keeps the key name out of the match so transforms replace
|
|
50
|
+
// only the secret value, not the assignment prefix.
|
|
51
|
+
pattern: "(?<=\\b(?:api[_-]?key|secret|token|password)\\s*[:=]\\s*['\\\"]?)[A-Za-z0-9._~+/-]{12,}",
|
|
52
|
+
flags: "gi",
|
|
53
|
+
confidence: 0.85
|
|
54
|
+
}
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export function createDefaultFilterEngine({ customRules = [] } = {}) {
|
|
58
|
+
const rules = DEFAULT_RULES.concat(customRules.map(normalizeCustomRule));
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: "haechi.filter.default",
|
|
62
|
+
version: "0.1.0",
|
|
63
|
+
capabilities: {
|
|
64
|
+
readsPlaintext: true,
|
|
65
|
+
networkEgress: false
|
|
66
|
+
},
|
|
67
|
+
async detect({ entries }) {
|
|
68
|
+
return entries.flatMap((entry) => detectEntry(entry, rules));
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function detectEntry(entry, rules) {
|
|
74
|
+
const detections = [];
|
|
75
|
+
|
|
76
|
+
for (const rule of rules) {
|
|
77
|
+
const regex = new RegExp(rule.pattern, rule.flags.includes("g") ? rule.flags : `${rule.flags}g`);
|
|
78
|
+
for (const match of entry.value.matchAll(regex)) {
|
|
79
|
+
const value = match[0];
|
|
80
|
+
if (rule.validate && !rule.validate(value)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
detections.push({
|
|
84
|
+
type: rule.type,
|
|
85
|
+
ruleId: rule.id,
|
|
86
|
+
path: entry.path,
|
|
87
|
+
pathText: entry.pathText,
|
|
88
|
+
kind: entry.kind ?? "value",
|
|
89
|
+
start: match.index,
|
|
90
|
+
end: match.index + value.length,
|
|
91
|
+
confidence: rule.confidence,
|
|
92
|
+
value
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return removeOverlaps(detections);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeCustomRule(rule) {
|
|
101
|
+
if (!rule.id || !rule.type || !rule.pattern) {
|
|
102
|
+
throw new Error("Custom filter rule requires id, type, and pattern");
|
|
103
|
+
}
|
|
104
|
+
validateCustomPattern(rule.pattern);
|
|
105
|
+
validateFlags(rule.flags ?? "g");
|
|
106
|
+
return {
|
|
107
|
+
id: rule.id,
|
|
108
|
+
type: rule.type,
|
|
109
|
+
pattern: rule.pattern,
|
|
110
|
+
flags: rule.flags ?? "g",
|
|
111
|
+
confidence: rule.confidence ?? 0.7
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateCustomPattern(pattern) {
|
|
116
|
+
if (pattern.length > 500) {
|
|
117
|
+
throw new Error("Custom filter rule pattern is too long");
|
|
118
|
+
}
|
|
119
|
+
if (/(?:\([^)]*[+*][^)]*\)){1}[+*{]/.test(pattern)) {
|
|
120
|
+
throw new Error("Custom filter rule pattern contains nested quantifiers");
|
|
121
|
+
}
|
|
122
|
+
if (/\\[1-9]/.test(pattern)) {
|
|
123
|
+
throw new Error("Custom filter rule pattern must not use backreferences");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function validateFlags(flags) {
|
|
128
|
+
if (!/^[dgimsuvy]*$/.test(flags)) {
|
|
129
|
+
throw new Error(`Invalid custom filter flags: ${flags}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function removeOverlaps(detections) {
|
|
134
|
+
const sorted = detections.sort((left, right) => {
|
|
135
|
+
if (left.start !== right.start) {
|
|
136
|
+
return left.start - right.start;
|
|
137
|
+
}
|
|
138
|
+
return (right.end - right.start) - (left.end - left.start);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const accepted = [];
|
|
142
|
+
let lastEnd = -1;
|
|
143
|
+
|
|
144
|
+
for (const detection of sorted) {
|
|
145
|
+
if (detection.start < lastEnd) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
accepted.push(detection);
|
|
149
|
+
lastEnd = detection.end;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return accepted;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function luhnValid(value) {
|
|
156
|
+
const digits = value.replace(/\D/g, "");
|
|
157
|
+
if (digits.length < 13 || digits.length > 19) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let sum = 0;
|
|
162
|
+
let alternate = false;
|
|
163
|
+
|
|
164
|
+
for (let index = digits.length - 1; index >= 0; index -= 1) {
|
|
165
|
+
let digit = Number(digits[index]);
|
|
166
|
+
if (alternate) {
|
|
167
|
+
digit *= 2;
|
|
168
|
+
if (digit > 9) {
|
|
169
|
+
digit -= 9;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
sum += digit;
|
|
173
|
+
alternate = !alternate;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return sum % 10 === 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function krRrnValid(value) {
|
|
180
|
+
const digits = value.replace(/\D/g, "");
|
|
181
|
+
if (digits.length !== 13) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const weights = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
|
|
186
|
+
const sum = weights.reduce((total, weight, index) => total + weight * Number(digits[index]), 0);
|
|
187
|
+
const check = (11 - (sum % 11)) % 10;
|
|
188
|
+
return check === Number(digits[12]);
|
|
189
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
|
|
3
|
+
export async function protectMcpJsonRpcMessage(message, runtime) {
|
|
4
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
|
5
|
+
throw new Error(Array.isArray(message)
|
|
6
|
+
? "JSON-RPC batch messages are not supported by the MCP stdio filter"
|
|
7
|
+
: "MCP message must be a JSON object");
|
|
8
|
+
}
|
|
9
|
+
const policy = runtime.config.mcp;
|
|
10
|
+
// JSON-RPC notifications (method, no id) must not receive responses; a
|
|
11
|
+
// rejected or blocked notification is dropped (returns null) instead.
|
|
12
|
+
const isNotification = message.method !== undefined
|
|
13
|
+
&& !Object.prototype.hasOwnProperty.call(message, "id");
|
|
14
|
+
if (policy.requireJsonRpc && message.jsonrpc !== "2.0") {
|
|
15
|
+
return isNotification ? null : errorJsonRpc(message.id, -32002, "haechi_mcp_invalid_jsonrpc", {
|
|
16
|
+
reason: "MCP messages must use JSON-RPC 2.0"
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
if (message.method && !methodAllowed(message.method, policy.allowedMethods)) {
|
|
20
|
+
return isNotification ? null : errorJsonRpc(message.id, -32003, "haechi_mcp_method_not_allowed", {
|
|
21
|
+
method: message.method
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const next = structuredClone(message);
|
|
26
|
+
|
|
27
|
+
if (policy.protectParams && Object.prototype.hasOwnProperty.call(next, "params")) {
|
|
28
|
+
const result = await runtime.haechi.protectJson(next.params, {
|
|
29
|
+
protocol: "mcp-stdio",
|
|
30
|
+
operation: next.method ?? "params",
|
|
31
|
+
mode: runtime.config.policy.mode ?? runtime.config.mode
|
|
32
|
+
});
|
|
33
|
+
if (result.blocked) {
|
|
34
|
+
return isNotification ? null : blockedJsonRpc(next.id, result);
|
|
35
|
+
}
|
|
36
|
+
next.params = result.payload;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (policy.protectResults && Object.prototype.hasOwnProperty.call(next, "result")) {
|
|
40
|
+
const result = await runtime.haechi.protectJson(next.result, {
|
|
41
|
+
protocol: "mcp-stdio",
|
|
42
|
+
operation: "result",
|
|
43
|
+
mode: runtime.config.policy.mode ?? runtime.config.mode
|
|
44
|
+
});
|
|
45
|
+
if (result.blocked) {
|
|
46
|
+
return blockedJsonRpc(next.id, result);
|
|
47
|
+
}
|
|
48
|
+
next.result = result.payload;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return next;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function runMcpStdioFilter({ input = process.stdin, output = process.stdout, runtime }) {
|
|
55
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
56
|
+
|
|
57
|
+
for await (const line of lines) {
|
|
58
|
+
if (!line.trim()) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const message = JSON.parse(line);
|
|
63
|
+
const protectedMessage = await protectMcpJsonRpcMessage(message, runtime);
|
|
64
|
+
if (protectedMessage === null) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
output.write(`${JSON.stringify(protectedMessage)}\n`);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
output.write(`${JSON.stringify({
|
|
70
|
+
jsonrpc: "2.0",
|
|
71
|
+
error: {
|
|
72
|
+
code: -32000,
|
|
73
|
+
message: "haechi_mcp_stdio_error",
|
|
74
|
+
data: {
|
|
75
|
+
reason: error.message
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
id: null
|
|
79
|
+
})}\n`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function blockedJsonRpc(id, result) {
|
|
85
|
+
return errorJsonRpc(id, -32001, "haechi_policy_block", {
|
|
86
|
+
auditId: result.auditEvent.id,
|
|
87
|
+
summary: result.summary
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function errorJsonRpc(id, code, message, data) {
|
|
92
|
+
return {
|
|
93
|
+
jsonrpc: "2.0",
|
|
94
|
+
error: {
|
|
95
|
+
code,
|
|
96
|
+
message,
|
|
97
|
+
data
|
|
98
|
+
},
|
|
99
|
+
id: id ?? null
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function methodAllowed(method, allowedMethods) {
|
|
104
|
+
return allowedMethods.includes("*") || allowedMethods.includes(method);
|
|
105
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
const VALID_KINDS = new Set([
|
|
4
|
+
"crypto-provider",
|
|
5
|
+
"key-provider",
|
|
6
|
+
"policy-engine",
|
|
7
|
+
"filter-engine",
|
|
8
|
+
"token-vault",
|
|
9
|
+
"audit-sink",
|
|
10
|
+
"protocol-adapter",
|
|
11
|
+
"classifier-plugin"
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const CAPABILITY_KEYS = [
|
|
15
|
+
"readsPlaintext",
|
|
16
|
+
"writesPlaintext",
|
|
17
|
+
"networkEgress",
|
|
18
|
+
"fileWrite",
|
|
19
|
+
"auditWrite",
|
|
20
|
+
"externalSecrets"
|
|
21
|
+
];
|
|
22
|
+
const VALID_RUNTIMES = new Set(["manifest-only"]);
|
|
23
|
+
|
|
24
|
+
export async function validatePluginManifestFile(path) {
|
|
25
|
+
const manifest = JSON.parse(await readFile(path, "utf8"));
|
|
26
|
+
return validatePluginManifest(manifest);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validatePluginManifest(manifest) {
|
|
30
|
+
const plugin = manifest?.haechiPlugin;
|
|
31
|
+
const errors = [];
|
|
32
|
+
|
|
33
|
+
if (!plugin) {
|
|
34
|
+
errors.push("missing haechiPlugin root");
|
|
35
|
+
} else {
|
|
36
|
+
requireString(plugin, "id", errors);
|
|
37
|
+
requireString(plugin, "version", errors);
|
|
38
|
+
requireString(plugin, "kind", errors);
|
|
39
|
+
requireString(plugin, "runtime", errors);
|
|
40
|
+
requireString(plugin, "entrypoint", errors);
|
|
41
|
+
|
|
42
|
+
if (plugin.kind && !VALID_KINDS.has(plugin.kind)) {
|
|
43
|
+
errors.push(`invalid kind: ${plugin.kind}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (plugin.runtime && !VALID_RUNTIMES.has(plugin.runtime)) {
|
|
47
|
+
errors.push("dynamic plugin execution is not supported; set runtime to manifest-only");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!plugin.compatibility?.haechiCore) {
|
|
51
|
+
errors.push("missing compatibility.haechiCore");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!plugin.capabilities || typeof plugin.capabilities !== "object") {
|
|
55
|
+
errors.push("missing capabilities");
|
|
56
|
+
} else {
|
|
57
|
+
for (const key of CAPABILITY_KEYS) {
|
|
58
|
+
if (typeof plugin.capabilities[key] !== "boolean") {
|
|
59
|
+
errors.push(`capabilities.${key} must be boolean`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (plugin.capabilities?.networkEgress && plugin.capabilities.readsPlaintext && !plugin.dataHandling?.retention) {
|
|
65
|
+
errors.push("plaintext-reading network plugins must declare dataHandling.retention");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (plugin.dataHandling?.logsRawPayload === true) {
|
|
69
|
+
errors.push("dataHandling.logsRawPayload must not be true");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
valid: errors.length === 0,
|
|
75
|
+
errors
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function requireString(object, key, errors) {
|
|
80
|
+
if (!object[key] || typeof object[key] !== "string") {
|
|
81
|
+
errors.push(`missing ${key}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const PRESETS = {
|
|
2
|
+
"llm-redact": {
|
|
3
|
+
defaultAction: "redact",
|
|
4
|
+
actions: {
|
|
5
|
+
email: "redact",
|
|
6
|
+
phone: "mask"
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"korean-pii": {
|
|
10
|
+
actions: {
|
|
11
|
+
kr_rrn: "block",
|
|
12
|
+
phone: "mask",
|
|
13
|
+
email: "redact"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"secrets-only": {
|
|
17
|
+
actions: {
|
|
18
|
+
api_key: "block",
|
|
19
|
+
secret: "block"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"strict-block": {
|
|
23
|
+
defaultAction: "block"
|
|
24
|
+
},
|
|
25
|
+
"local-only": {
|
|
26
|
+
transfer: {
|
|
27
|
+
allowExternal: false
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"mcp-basic": {
|
|
31
|
+
defaultAction: "redact",
|
|
32
|
+
actions: {
|
|
33
|
+
api_key: "block",
|
|
34
|
+
secret: "block",
|
|
35
|
+
kr_rrn: "block"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"local-inference": {
|
|
39
|
+
defaultAction: "redact",
|
|
40
|
+
actions: {
|
|
41
|
+
email: "tokenize",
|
|
42
|
+
phone: "mask",
|
|
43
|
+
api_key: "block",
|
|
44
|
+
secret: "block",
|
|
45
|
+
kr_rrn: "block"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const VALID_ACTIONS = new Set(["allow", "redact", "mask", "tokenize", "encrypt", "block"]);
|
|
51
|
+
export const ACTION_STRENGTH = {
|
|
52
|
+
allow: 0,
|
|
53
|
+
redact: 1,
|
|
54
|
+
mask: 1,
|
|
55
|
+
tokenize: 2,
|
|
56
|
+
encrypt: 2,
|
|
57
|
+
block: 3
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function buildPolicy({
|
|
61
|
+
presets = [],
|
|
62
|
+
mode = "dry-run",
|
|
63
|
+
defaultAction = "redact",
|
|
64
|
+
actions = {},
|
|
65
|
+
customRules = [],
|
|
66
|
+
allowUnsafeOverrides = false
|
|
67
|
+
} = {}) {
|
|
68
|
+
const merged = {
|
|
69
|
+
mode,
|
|
70
|
+
defaultAction,
|
|
71
|
+
actions: {},
|
|
72
|
+
customRules,
|
|
73
|
+
allowUnsafeOverrides
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
for (const presetName of presets) {
|
|
77
|
+
const preset = PRESETS[presetName];
|
|
78
|
+
if (!preset) {
|
|
79
|
+
throw new Error(`Unknown policy preset: ${presetName}`);
|
|
80
|
+
}
|
|
81
|
+
if (preset.defaultAction) {
|
|
82
|
+
merged.defaultAction = preset.defaultAction;
|
|
83
|
+
}
|
|
84
|
+
for (const [type, action] of Object.entries(preset.actions ?? {})) {
|
|
85
|
+
mergeAction(merged.actions, type, action, {
|
|
86
|
+
source: `preset:${presetName}`,
|
|
87
|
+
allowUnsafeOverrides
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const [type, action] of Object.entries(actions)) {
|
|
93
|
+
mergeAction(merged.actions, type, action, {
|
|
94
|
+
source: "policy.actions",
|
|
95
|
+
allowUnsafeOverrides
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
validatePolicy(merged);
|
|
99
|
+
return merged;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createPolicyEngine(policy) {
|
|
103
|
+
validatePolicy(policy);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
id: "haechi.policy.reference",
|
|
107
|
+
version: "0.1.0",
|
|
108
|
+
capabilities: {
|
|
109
|
+
readsPlaintext: false,
|
|
110
|
+
networkEgress: false
|
|
111
|
+
},
|
|
112
|
+
async decide({ detection, mode }) {
|
|
113
|
+
const action = policy.actions[detection.type] ?? policy.defaultAction ?? "redact";
|
|
114
|
+
return {
|
|
115
|
+
action,
|
|
116
|
+
reason: `matched:${detection.ruleId}`,
|
|
117
|
+
mode: mode ?? policy.mode ?? "dry-run"
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
async validatePolicy(candidate) {
|
|
121
|
+
validatePolicy(candidate);
|
|
122
|
+
return { valid: true };
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function validatePolicy(policy) {
|
|
128
|
+
if (!policy || typeof policy !== "object") {
|
|
129
|
+
throw new Error("Policy must be an object");
|
|
130
|
+
}
|
|
131
|
+
if (policy.defaultAction && !VALID_ACTIONS.has(policy.defaultAction)) {
|
|
132
|
+
throw new Error(`Invalid default action: ${policy.defaultAction}`);
|
|
133
|
+
}
|
|
134
|
+
for (const [type, action] of Object.entries(policy.actions ?? {})) {
|
|
135
|
+
if (!VALID_ACTIONS.has(action)) {
|
|
136
|
+
throw new Error(`Invalid action for ${type}: ${action}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (policy.mode && !["dry-run", "report-only", "enforce"].includes(policy.mode)) {
|
|
140
|
+
throw new Error(`Invalid policy mode: ${policy.mode}`);
|
|
141
|
+
}
|
|
142
|
+
if (policy.allowUnsafeOverrides !== undefined && typeof policy.allowUnsafeOverrides !== "boolean") {
|
|
143
|
+
throw new Error("allowUnsafeOverrides must be boolean");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function mergeAction(target, type, action, { source, allowUnsafeOverrides }) {
|
|
148
|
+
if (!VALID_ACTIONS.has(action)) {
|
|
149
|
+
throw new Error(`Invalid action for ${type}: ${action}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const existing = target[type];
|
|
153
|
+
if (!existing) {
|
|
154
|
+
target[type] = action;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (ACTION_STRENGTH[action] < ACTION_STRENGTH[existing] && !allowUnsafeOverrides) {
|
|
159
|
+
throw new Error(`Policy action conflict for ${type}: ${source} cannot weaken ${existing} to ${action}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (ACTION_STRENGTH[action] >= ACTION_STRENGTH[existing]) {
|
|
163
|
+
target[type] = action;
|
|
164
|
+
}
|
|
165
|
+
}
|