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,91 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { canonicalize } from "../crypto/index.mjs";
|
|
5
|
+
|
|
6
|
+
const ALG = "HS256";
|
|
7
|
+
|
|
8
|
+
export async function signPolicyBundleFile({ policyPath, keyFile, outPath }) {
|
|
9
|
+
const policy = JSON.parse(await readFile(policyPath, "utf8"));
|
|
10
|
+
const bundle = signPolicyBundle(policy, { keyFile });
|
|
11
|
+
await writeFile(outPath, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
|
|
12
|
+
return bundle;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function signPolicyBundle(policy, { keyFile }) {
|
|
16
|
+
const key = loadActiveKey(keyFile);
|
|
17
|
+
const payload = {
|
|
18
|
+
version: 1,
|
|
19
|
+
alg: ALG,
|
|
20
|
+
kid: key.kid,
|
|
21
|
+
signedAt: new Date().toISOString(),
|
|
22
|
+
policy
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
...payload,
|
|
26
|
+
signature: hmac(key.key, payload)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function verifyPolicyBundleFile({ bundlePath, keyFile }) {
|
|
31
|
+
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
|
|
32
|
+
return verifyPolicyBundle(bundle, { keyFile });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loadVerifiedPolicyBundleFileSync({ bundlePath, keyFile }) {
|
|
36
|
+
const bundle = JSON.parse(readFileSync(bundlePath, "utf8"));
|
|
37
|
+
return verifyPolicyBundle(bundle, { keyFile });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function verifyPolicyBundle(bundle, { keyFile }) {
|
|
41
|
+
if (!bundle || bundle.alg !== ALG || !bundle.policy || !bundle.signature) {
|
|
42
|
+
throw new Error("Invalid policy bundle");
|
|
43
|
+
}
|
|
44
|
+
const key = loadActiveKey(keyFile, bundle.kid);
|
|
45
|
+
const payload = {
|
|
46
|
+
version: bundle.version,
|
|
47
|
+
alg: bundle.alg,
|
|
48
|
+
kid: bundle.kid,
|
|
49
|
+
signedAt: bundle.signedAt,
|
|
50
|
+
policy: bundle.policy
|
|
51
|
+
};
|
|
52
|
+
const expected = hmac(key.key, payload);
|
|
53
|
+
if (!safeEqual(expected, bundle.signature)) {
|
|
54
|
+
throw new Error("Policy bundle signature verification failed");
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
valid: true,
|
|
58
|
+
kid: bundle.kid,
|
|
59
|
+
signedAt: bundle.signedAt,
|
|
60
|
+
policy: bundle.policy
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function loadActiveKey(keyFile, kid = null) {
|
|
65
|
+
const raw = JSON.parse(readFileSync(keyFile, "utf8"));
|
|
66
|
+
const selected = kid
|
|
67
|
+
? raw.keys.find((key) => key.kid === kid)
|
|
68
|
+
: raw.keys.find((key) => key.status === "active") ?? raw.keys[0];
|
|
69
|
+
if (!selected) {
|
|
70
|
+
throw new Error(`Signing key not found: ${kid ?? "active"}`);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
kid: selected.kid,
|
|
74
|
+
key: Buffer.from(selected.k, "base64url")
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const SIGNING_KEY_DOMAIN = "haechi:policy-bundle:signing:v1";
|
|
79
|
+
|
|
80
|
+
function hmac(key, payload) {
|
|
81
|
+
// Domain-separated signing key: the stored key material doubles as the
|
|
82
|
+
// AES-256-GCM encryption key, so it must never be used for HMAC directly.
|
|
83
|
+
const signingKey = createHmac("sha256", key).update(SIGNING_KEY_DOMAIN).digest();
|
|
84
|
+
return createHmac("sha256", signingKey).update(canonicalize(payload)).digest("base64url");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function safeEqual(left, right) {
|
|
88
|
+
const leftBuffer = Buffer.from(left);
|
|
89
|
+
const rightBuffer = Buffer.from(right);
|
|
90
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
91
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ACTION_STRENGTH } from "../policy/index.mjs";
|
|
2
|
+
|
|
3
|
+
const PROFILES = {
|
|
4
|
+
"kr-pipa": {
|
|
5
|
+
id: "kr-pipa",
|
|
6
|
+
region: "KR",
|
|
7
|
+
regulations: ["PIPA", "Credit Information Act"],
|
|
8
|
+
policy: {
|
|
9
|
+
actions: {
|
|
10
|
+
kr_rrn: "block",
|
|
11
|
+
phone: "mask",
|
|
12
|
+
email: "redact",
|
|
13
|
+
card: "block",
|
|
14
|
+
api_key: "block",
|
|
15
|
+
secret: "block"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
transfer: {
|
|
19
|
+
requiresAssessment: true,
|
|
20
|
+
note: "Document cross-border transfer purpose, recipient, retention, and user notice before production use."
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"eu-gdpr": {
|
|
24
|
+
id: "eu-gdpr",
|
|
25
|
+
region: "EU",
|
|
26
|
+
regulations: ["GDPR"],
|
|
27
|
+
policy: {
|
|
28
|
+
actions: {
|
|
29
|
+
email: "tokenize",
|
|
30
|
+
phone: "mask",
|
|
31
|
+
card: "block",
|
|
32
|
+
api_key: "block",
|
|
33
|
+
secret: "block",
|
|
34
|
+
kr_rrn: "block"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
transfer: {
|
|
38
|
+
requiresAssessment: true,
|
|
39
|
+
note: "Treat model/tool transfer as processor/subprocessor transfer and document SCC/TIA evidence outside Haechi."
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"us-general": {
|
|
43
|
+
id: "us-general",
|
|
44
|
+
region: "US",
|
|
45
|
+
regulations: ["CCPA/CPRA", "HIPAA-sensitive deployments require separate controls"],
|
|
46
|
+
policy: {
|
|
47
|
+
actions: {
|
|
48
|
+
email: "redact",
|
|
49
|
+
phone: "mask",
|
|
50
|
+
card: "block",
|
|
51
|
+
api_key: "block",
|
|
52
|
+
secret: "block"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
transfer: {
|
|
56
|
+
requiresAssessment: false,
|
|
57
|
+
note: "Classify sector rules separately before using protected health, payment, or children's data."
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function listPrivacyProfiles() {
|
|
63
|
+
return Object.values(PROFILES).map((profile) => structuredClone(profile));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getPrivacyProfile(id) {
|
|
67
|
+
const profile = PROFILES[id];
|
|
68
|
+
if (!profile) {
|
|
69
|
+
throw new Error(`Unknown privacy profile: ${id}`);
|
|
70
|
+
}
|
|
71
|
+
return structuredClone(profile);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function applyPrivacyProfile(policy = {}, profileId) {
|
|
75
|
+
const profile = getPrivacyProfile(profileId);
|
|
76
|
+
const actions = { ...(policy.actions ?? {}) };
|
|
77
|
+
|
|
78
|
+
// Profiles are baseline defaults: they may strengthen an action but must
|
|
79
|
+
// never silently weaken an explicitly stricter user setting.
|
|
80
|
+
for (const [type, action] of Object.entries(profile.policy.actions)) {
|
|
81
|
+
const existing = actions[type];
|
|
82
|
+
if (!existing || ACTION_STRENGTH[action] > ACTION_STRENGTH[existing]) {
|
|
83
|
+
actions[type] = action;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
...policy,
|
|
89
|
+
privacyProfile: profile.id,
|
|
90
|
+
actions
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const ADAPTERS = {
|
|
2
|
+
"openai-compatible": {
|
|
3
|
+
id: "openai-compatible",
|
|
4
|
+
protocol: "llm-http",
|
|
5
|
+
routes: [
|
|
6
|
+
route("/v1/chat/completions", "chat-completions"),
|
|
7
|
+
route("/v1/completions", "completions"),
|
|
8
|
+
route("/v1/responses", "responses"),
|
|
9
|
+
route("/v1/embeddings", "embeddings")
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"vllm-openai": {
|
|
13
|
+
id: "vllm-openai",
|
|
14
|
+
protocol: "vllm-openai",
|
|
15
|
+
routes: [
|
|
16
|
+
route("/v1/chat/completions", "chat-completions"),
|
|
17
|
+
route("/v1/completions", "completions"),
|
|
18
|
+
route("/v1/responses", "responses"),
|
|
19
|
+
route("/v1/embeddings", "embeddings")
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"llama-cpp": {
|
|
23
|
+
id: "llama-cpp",
|
|
24
|
+
protocol: "llama-cpp",
|
|
25
|
+
routes: [
|
|
26
|
+
route("/v1/chat/completions", "chat-completions"),
|
|
27
|
+
route("/v1/completions", "completions"),
|
|
28
|
+
route("/v1/embeddings", "embeddings"),
|
|
29
|
+
route("/completion", "legacy-completion")
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"ollama": {
|
|
33
|
+
id: "ollama",
|
|
34
|
+
protocol: "ollama",
|
|
35
|
+
routes: [
|
|
36
|
+
// Ollama streams /api/chat and /api/generate unless the request sets stream:false.
|
|
37
|
+
route("/api/chat", "chat", { streamingDefault: true }),
|
|
38
|
+
route("/api/generate", "generate", { streamingDefault: true }),
|
|
39
|
+
route("/api/embed", "embed"),
|
|
40
|
+
route("/api/embeddings", "embeddings")
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const TARGET_TYPE_ALIASES = {
|
|
46
|
+
"llm-http": "openai-compatible"
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function createProtocolAdapter(target = {}) {
|
|
50
|
+
const adapterId = target.adapter ?? adapterFromTargetType(target.type);
|
|
51
|
+
const adapter = ADAPTERS[adapterId];
|
|
52
|
+
if (!adapter) {
|
|
53
|
+
throw new Error(`Unknown protocol adapter: ${adapterId}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
id: adapter.id,
|
|
58
|
+
protocol: adapter.protocol,
|
|
59
|
+
classifyRequest(request) {
|
|
60
|
+
const pathname = pathFromRequestUrl(request.url);
|
|
61
|
+
const matched = matchRoute(adapter.routes, pathname);
|
|
62
|
+
const operation = matched
|
|
63
|
+
? `${request.method} ${matched.operation}`
|
|
64
|
+
: `${request.method} ${pathname}`;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
adapterId: adapter.id,
|
|
68
|
+
protocol: adapter.protocol,
|
|
69
|
+
routeId: matched?.id ?? "unknown",
|
|
70
|
+
path: pathname,
|
|
71
|
+
operation,
|
|
72
|
+
protectRequest: matched?.protectRequest ?? true,
|
|
73
|
+
protectResponse: matched?.protectResponse ?? true,
|
|
74
|
+
streamingByDefault: matched?.streamingDefault ?? false
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function knownProtocolAdapters() {
|
|
81
|
+
return Object.keys(ADAPTERS);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function adapterFromTargetType(type = "llm-http") {
|
|
85
|
+
if (ADAPTERS[type]) {
|
|
86
|
+
return type;
|
|
87
|
+
}
|
|
88
|
+
if (TARGET_TYPE_ALIASES[type]) {
|
|
89
|
+
return TARGET_TYPE_ALIASES[type];
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`Unknown target.type: ${type}. Known types: ${["llm-http", ...Object.keys(ADAPTERS)].join(", ")}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function route(path, operation, options = {}) {
|
|
95
|
+
return {
|
|
96
|
+
id: operation,
|
|
97
|
+
path,
|
|
98
|
+
operation,
|
|
99
|
+
protectRequest: options.protectRequest ?? true,
|
|
100
|
+
protectResponse: options.protectResponse ?? true,
|
|
101
|
+
streamingDefault: options.streamingDefault ?? false
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function pathFromRequestUrl(url) {
|
|
106
|
+
return new URL(url, "http://haechi.local").pathname;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function matchRoute(routes, pathname) {
|
|
110
|
+
return routes.find((candidate) => candidate.path === pathname);
|
|
111
|
+
}
|