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.
Files changed (54) hide show
  1. package/LICENSE +154 -0
  2. package/README.md +102 -0
  3. package/SECURITY.md +31 -0
  4. package/docs/README.md +35 -0
  5. package/docs/current/api-stability.ko.md +48 -0
  6. package/docs/current/api-stability.md +48 -0
  7. package/docs/current/expert-gap-review-ai-llm-mcp-encryption.ko.md +107 -0
  8. package/docs/current/expert-gap-review-ai-llm-mcp-encryption.md +107 -0
  9. package/docs/current/global-privacy-compliance-review.ko.md +110 -0
  10. package/docs/current/global-privacy-compliance-review.md +110 -0
  11. package/docs/current/initial-plan-ai-llm-mcp-encryption.ko.md +214 -0
  12. package/docs/current/initial-plan-ai-llm-mcp-encryption.md +214 -0
  13. package/docs/current/mvp-0.1-implementation-scope.ko.md +79 -0
  14. package/docs/current/mvp-0.1-implementation-scope.md +79 -0
  15. package/docs/current/open-source-modular-architecture.ko.md +387 -0
  16. package/docs/current/open-source-modular-architecture.md +387 -0
  17. package/docs/current/prd-ai-llm-mcp-encryption.ko.md +260 -0
  18. package/docs/current/prd-ai-llm-mcp-encryption.md +262 -0
  19. package/docs/current/privacy-filtering-policy-draft.ko.md +307 -0
  20. package/docs/current/privacy-filtering-policy-draft.md +307 -0
  21. package/docs/current/release-0.2-implementation-scope.ko.md +46 -0
  22. package/docs/current/release-0.2-implementation-scope.md +46 -0
  23. package/docs/current/release-0.3-implementation-scope.ko.md +86 -0
  24. package/docs/current/release-0.3-implementation-scope.md +86 -0
  25. package/docs/current/release-0.3.2-hardening-scope.ko.md +64 -0
  26. package/docs/current/release-0.3.2-hardening-scope.md +64 -0
  27. package/docs/current/release-0.4-implementation-scope.ko.md +121 -0
  28. package/docs/current/release-0.4-implementation-scope.md +121 -0
  29. package/docs/current/release-process.ko.md +48 -0
  30. package/docs/current/release-process.md +48 -0
  31. package/docs/current/risk-register-release-gate.ko.md +154 -0
  32. package/docs/current/risk-register-release-gate.md +154 -0
  33. package/docs/current/shared-responsibility.ko.md +38 -0
  34. package/docs/current/shared-responsibility.md +38 -0
  35. package/docs/current/threat-model.ko.md +68 -0
  36. package/docs/current/threat-model.md +68 -0
  37. package/examples/llm-prompt-filtering/input.json +13 -0
  38. package/examples/plugins/custom-filter.plugin.json +29 -0
  39. package/haechi.config.example.json +70 -0
  40. package/package.json +74 -0
  41. package/packages/audit/index.mjs +262 -0
  42. package/packages/cli/bin/haechi.mjs +341 -0
  43. package/packages/cli/runtime.mjs +287 -0
  44. package/packages/core/index.mjs +309 -0
  45. package/packages/crypto/index.mjs +142 -0
  46. package/packages/filter/index.mjs +189 -0
  47. package/packages/mcp-stdio/index.mjs +105 -0
  48. package/packages/plugin/index.mjs +83 -0
  49. package/packages/policy/index.mjs +165 -0
  50. package/packages/policy-bundle/index.mjs +91 -0
  51. package/packages/privacy-profiles/index.mjs +92 -0
  52. package/packages/protocol-adapters/index.mjs +111 -0
  53. package/packages/proxy/index.mjs +534 -0
  54. 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
+ }