haechi 0.5.0 → 0.6.0
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.ko.md +37 -0
- package/README.md +37 -0
- package/docs/README.md +1 -0
- package/docs/current/api-stability.ko.md +3 -1
- package/docs/current/api-stability.md +3 -1
- package/docs/current/configuration.ko.md +25 -2
- package/docs/current/configuration.md +25 -2
- package/docs/current/release-0.6-implementation-scope.ko.md +151 -0
- package/docs/current/release-0.6-implementation-scope.md +151 -0
- package/docs/current/risk-register-release-gate.ko.md +2 -2
- package/docs/current/risk-register-release-gate.md +3 -2
- package/docs/current/threat-model.ko.md +3 -1
- package/docs/current/threat-model.md +3 -1
- package/haechi.config.example.json +10 -0
- package/package.json +3 -2
- package/packages/auth/index.mjs +170 -0
- package/packages/cli/bin/haechi.mjs +91 -6
- package/packages/cli/runtime.mjs +103 -5
- package/packages/core/index.mjs +18 -7
- package/packages/policy/index.mjs +82 -0
- package/packages/proxy/index.mjs +134 -8
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- Status: Draft 0.1
|
|
4
4
|
- Date: 2026-06-10
|
|
5
|
-
- Target version: 0.
|
|
5
|
+
- Target version: 0.6.0
|
|
6
6
|
|
|
7
7
|
## 1. Assets Under Protection
|
|
8
8
|
|
|
@@ -47,6 +47,8 @@ The primary assets Haechi protects are:
|
|
|
47
47
|
| Hung upstream | Proxy connection exhaustion | `limits.upstreamTimeoutMs` default 120 s; 504 fail on timeout |
|
|
48
48
|
| Signing/encryption key conflation | Key separation violation | Policy bundle signing key isolated as a domain-separated derived key |
|
|
49
49
|
| JSON number / object key concealment | Undetected non-string leaves such as card numbers | Number leaves and object keys included in detection/transform scope |
|
|
50
|
+
| Unauthenticated multi-client access | Any local process uses the upstream / token round-trip | Optional bearer auth (`auth.provider: bearer`); missing/invalid → 401 before body read; per-identity rate limit and model allowlist |
|
|
51
|
+
| Raw credentials/identity in audit | Token or subject leak through the audit log | Tokens stored only as keyed-HMAC hashes; identity subject/issuer are keyed HMAC; `auth_denied` records no token |
|
|
50
52
|
| Token round-trip restoring foreign tokens | Cross-client/request plaintext recovery | Detokenization is opt-in (`detokenizeResponses`) and request-scoped: only tokens issued while protecting the same request are restored |
|
|
51
53
|
| Indirect prompt injection in tool results/responses | Agent manipulation via planted instructions | Response-direction heuristics, report-only by default (`injection` action `allow`); escalation is an explicit policy choice. Not a complete defense |
|
|
52
54
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haechi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Experimental developer preview for self-hosted AI context enforcement across LLM, MCP, vLLM, Ollama, and agent traffic.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"./proxy": "./packages/proxy/index.mjs",
|
|
46
46
|
"./runtime": "./packages/cli/runtime.mjs",
|
|
47
47
|
"./token-vault": "./packages/token-vault/index.mjs",
|
|
48
|
-
"./stream-filter": "./packages/stream-filter/index.mjs"
|
|
48
|
+
"./stream-filter": "./packages/stream-filter/index.mjs",
|
|
49
|
+
"./auth": "./packages/auth/index.mjs"
|
|
49
50
|
},
|
|
50
51
|
"files": [
|
|
51
52
|
"README.md",
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Built-in bearer authentication and the authProvider contract.
|
|
2
|
+
//
|
|
3
|
+
// Tokens are never stored in plaintext: the store keeps a keyed-HMAC hash
|
|
4
|
+
// (domain-separated, never a bare hash) plus PII-safe metadata. The plaintext
|
|
5
|
+
// token is shown once at creation. Identity objects are PII-safe by
|
|
6
|
+
// construction — subject/issuer are keyed HMACs, never raw values.
|
|
7
|
+
|
|
8
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
const TOKEN_DOMAIN = "haechi:auth:token:v1";
|
|
13
|
+
const IDENTITY_DOMAIN = "haechi:identity:hash:v1";
|
|
14
|
+
const TOKEN_PREFIX = "hae_";
|
|
15
|
+
const DEFAULT_ALLOWED_LABEL_KEYS = ["team", "env", "tier", "role"];
|
|
16
|
+
const VALID_IDENTITY_TYPES = new Set(["user", "service", "agent"]);
|
|
17
|
+
const MAX_LABEL_VALUE_LENGTH = 64;
|
|
18
|
+
|
|
19
|
+
export async function readAuthStore(path) {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
22
|
+
return { version: parsed.version ?? 1, tokens: parsed.tokens ?? [] };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error.code === "ENOENT") {
|
|
25
|
+
return { version: 1, tokens: [] };
|
|
26
|
+
}
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function writeAuthStore(path, store) {
|
|
32
|
+
await mkdir(dirname(path), { recursive: true });
|
|
33
|
+
const tempPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
34
|
+
await writeFile(tempPath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
|
35
|
+
await rename(tempPath, path);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateLabels(labels, allowedLabelKeys = DEFAULT_ALLOWED_LABEL_KEYS) {
|
|
39
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
40
|
+
if (!allowedLabelKeys.includes(key)) {
|
|
41
|
+
throw new Error(`Label key not allowed: ${key} (allowed: ${allowedLabelKeys.join(", ") || "none"})`);
|
|
42
|
+
}
|
|
43
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_LABEL_VALUE_LENGTH) {
|
|
44
|
+
throw new Error(`Label value for ${key} must be a non-empty string up to ${MAX_LABEL_VALUE_LENGTH} chars`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return labels;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function addToken({ path, cryptoProvider, type, scopes = [], labels = {}, allowedLabelKeys = DEFAULT_ALLOWED_LABEL_KEYS }) {
|
|
51
|
+
if (!VALID_IDENTITY_TYPES.has(type)) {
|
|
52
|
+
throw new Error(`Invalid token type: ${type} (expected user | service | agent)`);
|
|
53
|
+
}
|
|
54
|
+
if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string" && scope.trim())) {
|
|
55
|
+
throw new Error("scopes must be an array of non-empty strings");
|
|
56
|
+
}
|
|
57
|
+
validateLabels(labels, allowedLabelKeys);
|
|
58
|
+
|
|
59
|
+
const token = `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
|
|
60
|
+
const tokenHash = await cryptoProvider.hmac({ data: token, domain: TOKEN_DOMAIN });
|
|
61
|
+
const record = {
|
|
62
|
+
id: `tok_auth_${randomUUID().slice(0, 8)}`,
|
|
63
|
+
tokenHash,
|
|
64
|
+
type,
|
|
65
|
+
scopes,
|
|
66
|
+
labels,
|
|
67
|
+
createdAt: new Date().toISOString(),
|
|
68
|
+
disabled: false
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const store = await readAuthStore(path);
|
|
72
|
+
store.tokens.push(record);
|
|
73
|
+
await writeAuthStore(path, store);
|
|
74
|
+
|
|
75
|
+
// The plaintext token is returned to the caller for one-time display only.
|
|
76
|
+
return { token, record: publicRecord(record) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function listTokens(path) {
|
|
80
|
+
const store = await readAuthStore(path);
|
|
81
|
+
return store.tokens.map(publicRecord);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function revokeToken({ path, id }) {
|
|
85
|
+
const store = await readAuthStore(path);
|
|
86
|
+
const record = store.tokens.find((entry) => entry.id === id);
|
|
87
|
+
if (!record) {
|
|
88
|
+
throw new Error(`Unknown token id: ${id}`);
|
|
89
|
+
}
|
|
90
|
+
const changed = !record.disabled;
|
|
91
|
+
record.disabled = true;
|
|
92
|
+
await writeAuthStore(path, store);
|
|
93
|
+
return { id, revoked: changed };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function publicRecord(record) {
|
|
97
|
+
// Never expose the token or its hash.
|
|
98
|
+
return {
|
|
99
|
+
id: record.id,
|
|
100
|
+
type: record.type,
|
|
101
|
+
scopes: record.scopes,
|
|
102
|
+
labels: record.labels,
|
|
103
|
+
createdAt: record.createdAt,
|
|
104
|
+
disabled: Boolean(record.disabled)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function buildIdentity(record, cryptoProvider) {
|
|
109
|
+
return {
|
|
110
|
+
id: record.id,
|
|
111
|
+
type: record.type,
|
|
112
|
+
subjectHash: await cryptoProvider.hmac({ data: record.id, domain: IDENTITY_DOMAIN }),
|
|
113
|
+
issuerHash: await cryptoProvider.hmac({ data: "bearer-local", domain: IDENTITY_DOMAIN }),
|
|
114
|
+
provider: "bearer",
|
|
115
|
+
scopes: record.scopes ?? [],
|
|
116
|
+
labels: record.labels ?? {}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function bearerTokenFromRequest(request) {
|
|
121
|
+
const header = request?.headers?.authorization ?? request?.headers?.Authorization;
|
|
122
|
+
if (typeof header !== "string") {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
126
|
+
return match ? match[1].trim() : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function constantTimeHashMatch(candidateHash, storedHash) {
|
|
130
|
+
const a = Buffer.from(candidateHash, "utf8");
|
|
131
|
+
const b = Buffer.from(storedHash, "utf8");
|
|
132
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// authProvider contract: authenticate(request) -> identity | null. Fails closed
|
|
136
|
+
// (null/deny) for a missing/invalid/disabled token; throws are treated as deny
|
|
137
|
+
// by the caller.
|
|
138
|
+
export function createBearerAuthProvider({ path, cryptoProvider }) {
|
|
139
|
+
if (!path) {
|
|
140
|
+
throw new Error("Bearer auth provider requires a store path");
|
|
141
|
+
}
|
|
142
|
+
if (typeof cryptoProvider?.hmac !== "function") {
|
|
143
|
+
throw new Error("Bearer auth provider requires a cryptoProvider with hmac()");
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
id: "haechi.auth.bearer",
|
|
147
|
+
async authenticate(request) {
|
|
148
|
+
const token = bearerTokenFromRequest(request);
|
|
149
|
+
if (!token) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const candidateHash = await cryptoProvider.hmac({ data: token, domain: TOKEN_DOMAIN });
|
|
153
|
+
const store = await readAuthStore(path);
|
|
154
|
+
let matched = null;
|
|
155
|
+
// Scan every record (no early return) so timing does not reveal which
|
|
156
|
+
// token matched.
|
|
157
|
+
for (const record of store.tokens) {
|
|
158
|
+
if (!record.disabled && constantTimeHashMatch(candidateHash, record.tokenHash)) {
|
|
159
|
+
matched = record;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!matched) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return buildIdentity(matched, cryptoProvider);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export { DEFAULT_ALLOWED_LABEL_KEYS };
|
|
@@ -5,6 +5,8 @@ import { DEFAULT_PROXY_PORT, createHaechiProxy } from "../../proxy/index.mjs";
|
|
|
5
5
|
import { signPolicyBundleFile, verifyPolicyBundleFile } from "../../policy-bundle/index.mjs";
|
|
6
6
|
import { validatePluginManifestFile } from "../../plugin/index.mjs";
|
|
7
7
|
import { runMcpStdioFilter, wrapMcpChild } from "../../mcp-stdio/index.mjs";
|
|
8
|
+
import { addToken, listTokens, revokeToken } from "../../auth/index.mjs";
|
|
9
|
+
import { createLocalCryptoProvider } from "../../crypto/index.mjs";
|
|
8
10
|
import { spawn } from "node:child_process";
|
|
9
11
|
import { DEFAULT_CONFIG_PATH, createRuntime, isValidPort, loadConfig, writeDefaultConfig } from "../runtime.mjs";
|
|
10
12
|
|
|
@@ -55,6 +57,9 @@ try {
|
|
|
55
57
|
case "mcp-wrap":
|
|
56
58
|
await mcpWrapCommand(argv);
|
|
57
59
|
break;
|
|
60
|
+
case "auth":
|
|
61
|
+
await authCommand(argv);
|
|
62
|
+
break;
|
|
58
63
|
case "config":
|
|
59
64
|
printConfigGuide();
|
|
60
65
|
break;
|
|
@@ -402,6 +407,76 @@ async function pluginValidateCommand(argv) {
|
|
|
402
407
|
}
|
|
403
408
|
}
|
|
404
409
|
|
|
410
|
+
async function authCommand(argv) {
|
|
411
|
+
const [sub, ...rest] = argv;
|
|
412
|
+
const options = parseOptions(rest);
|
|
413
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
414
|
+
if (config.keys.provider !== "local") {
|
|
415
|
+
throw new Error("haechi auth requires keys.provider local (the bearer store is hashed with the local key)");
|
|
416
|
+
}
|
|
417
|
+
const cryptoProvider = createLocalCryptoProvider({ keyFile: config.keys.keyFile });
|
|
418
|
+
const storePath = config.auth.store;
|
|
419
|
+
|
|
420
|
+
switch (sub) {
|
|
421
|
+
case "add": {
|
|
422
|
+
if (!options.type || options.type === true) {
|
|
423
|
+
throw new Error("auth add requires --type user|service|agent");
|
|
424
|
+
}
|
|
425
|
+
const { token, record } = await addToken({
|
|
426
|
+
path: storePath,
|
|
427
|
+
cryptoProvider,
|
|
428
|
+
type: options.type,
|
|
429
|
+
scopes: asList(options.scope),
|
|
430
|
+
labels: asLabels(options.label),
|
|
431
|
+
allowedLabelKeys: config.auth.allowedLabelKeys
|
|
432
|
+
});
|
|
433
|
+
writeJson({
|
|
434
|
+
ok: true,
|
|
435
|
+
command: "auth add",
|
|
436
|
+
id: record.id,
|
|
437
|
+
type: record.type,
|
|
438
|
+
scopes: record.scopes,
|
|
439
|
+
labels: record.labels,
|
|
440
|
+
token,
|
|
441
|
+
warning: "This token is shown only once. Store it now; it is not recoverable."
|
|
442
|
+
});
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
case "list":
|
|
446
|
+
writeJson({ ok: true, command: "auth list", tokens: await listTokens(storePath) });
|
|
447
|
+
return;
|
|
448
|
+
case "revoke": {
|
|
449
|
+
const [id] = rest;
|
|
450
|
+
if (!id || id.startsWith("--")) {
|
|
451
|
+
throw new Error("auth revoke requires a token id");
|
|
452
|
+
}
|
|
453
|
+
writeJson({ ok: true, command: "auth revoke", result: await revokeToken({ path: storePath, id }) });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
default:
|
|
457
|
+
throw new Error("auth requires a subcommand: add | list | revoke");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function asList(value) {
|
|
462
|
+
if (!value || value === true) {
|
|
463
|
+
return [];
|
|
464
|
+
}
|
|
465
|
+
return Array.isArray(value) ? value : [value];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function asLabels(value) {
|
|
469
|
+
const labels = {};
|
|
470
|
+
for (const entry of asList(value)) {
|
|
471
|
+
const index = entry.indexOf("=");
|
|
472
|
+
if (index === -1) {
|
|
473
|
+
throw new Error(`Invalid --label (expected key=value): ${entry}`);
|
|
474
|
+
}
|
|
475
|
+
labels[entry.slice(0, index)] = entry.slice(index + 1);
|
|
476
|
+
}
|
|
477
|
+
return labels;
|
|
478
|
+
}
|
|
479
|
+
|
|
405
480
|
async function mcpStdioCommand(argv) {
|
|
406
481
|
const options = parseOptions(argv);
|
|
407
482
|
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
@@ -442,12 +517,17 @@ function parseOptions(argv) {
|
|
|
442
517
|
}
|
|
443
518
|
const key = arg.slice(2);
|
|
444
519
|
const next = argv[index + 1];
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
520
|
+
const value = (!next || next.startsWith("--")) ? true : next;
|
|
521
|
+
if (value !== true) {
|
|
522
|
+
index += 1;
|
|
523
|
+
}
|
|
524
|
+
// Repeated flags accumulate into an array (e.g. --scope a --scope b);
|
|
525
|
+
// a single occurrence stays scalar for backward compatibility.
|
|
526
|
+
if (Object.prototype.hasOwnProperty.call(options, key)) {
|
|
527
|
+
options[key] = Array.isArray(options[key]) ? [...options[key], value] : [options[key], value];
|
|
528
|
+
} else {
|
|
529
|
+
options[key] = value;
|
|
448
530
|
}
|
|
449
|
-
options[key] = next;
|
|
450
|
-
index += 1;
|
|
451
531
|
}
|
|
452
532
|
return options;
|
|
453
533
|
}
|
|
@@ -534,6 +614,11 @@ const COMMAND_HELP = {
|
|
|
534
614
|
summary: "Wrap an MCP server with bidirectional stdio protection.",
|
|
535
615
|
detail: "Spawns <command>, applies the method allowlist + params protection client→server, and result protection + injection heuristics server→client. Drop-in for MCP client configs."
|
|
536
616
|
},
|
|
617
|
+
auth: {
|
|
618
|
+
usage: "haechi auth add --type user|service|agent [--scope k:v ...] [--label k=v ...]\n haechi auth list [--config haechi.config.json]\n haechi auth revoke <id> [--config haechi.config.json]",
|
|
619
|
+
summary: "Manage built-in bearer tokens (separate store, hashed).",
|
|
620
|
+
detail: "Tokens are stored hashed in auth.store (default .haechi/auth.json, 0600). `add` prints the plaintext token once — it cannot be recovered. `list` never reveals tokens; `revoke` disables one by id."
|
|
621
|
+
},
|
|
537
622
|
config: {
|
|
538
623
|
usage: "haechi config",
|
|
539
624
|
summary: "Print the configuration guide (keys, defaults, common setups)."
|
|
@@ -551,7 +636,7 @@ function printHelp(topic) {
|
|
|
551
636
|
"init", "protect", "report", "status", "audit-verify", "proxy",
|
|
552
637
|
"policy-sign", "policy-verify",
|
|
553
638
|
"token-reveal", "token-purge", "token-export",
|
|
554
|
-
"plugin-validate", "mcp-stdio", "mcp-wrap", "config"
|
|
639
|
+
"plugin-validate", "mcp-stdio", "mcp-wrap", "auth", "config"
|
|
555
640
|
];
|
|
556
641
|
const lines = order.map((name) => ` ${name.padEnd(16)}${COMMAND_HELP[name].summary}`);
|
|
557
642
|
console.log(`Haechi — self-hosted AI context enforcement (developer preview)
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -2,13 +2,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { createHaechi } from "../core/index.mjs";
|
|
4
4
|
import { createDefaultFilterEngine } from "../filter/index.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { createPolicyProfiles } from "../policy/index.mjs";
|
|
6
6
|
import { createLocalCryptoProvider, initLocalKeyFile } from "../crypto/index.mjs";
|
|
7
7
|
import { createJsonlAuditSink } from "../audit/index.mjs";
|
|
8
8
|
import { createLocalTokenVault } from "../token-vault/index.mjs";
|
|
9
9
|
import { loadVerifiedPolicyBundleFileSync } from "../policy-bundle/index.mjs";
|
|
10
10
|
import { createProtocolAdapter } from "../protocol-adapters/index.mjs";
|
|
11
11
|
import { applyPrivacyProfile, getPrivacyProfile } from "../privacy-profiles/index.mjs";
|
|
12
|
+
import { createBearerAuthProvider } from "../auth/index.mjs";
|
|
12
13
|
import { DEFAULT_PROXY_PORT } from "../proxy/index.mjs";
|
|
13
14
|
|
|
14
15
|
export const DEFAULT_CONFIG_PATH = "haechi.config.json";
|
|
@@ -73,6 +74,11 @@ export function defaultConfig() {
|
|
|
73
74
|
privacy: {
|
|
74
75
|
profile: null
|
|
75
76
|
},
|
|
77
|
+
auth: {
|
|
78
|
+
provider: "none",
|
|
79
|
+
store: ".haechi/auth.json",
|
|
80
|
+
allowedLabelKeys: ["team", "env", "tier", "role"]
|
|
81
|
+
},
|
|
76
82
|
mcp: {
|
|
77
83
|
allowedMethods: ["initialize", "tools/call", "resources/read", "prompts/get"],
|
|
78
84
|
protectParams: true,
|
|
@@ -135,19 +141,25 @@ export function createRuntime(config, providers = {}) {
|
|
|
135
141
|
...normalized.policy,
|
|
136
142
|
mode: normalized.policy.mode ?? normalized.mode
|
|
137
143
|
};
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
144
|
+
const policyProfiles = createPolicyProfiles(policySource, {
|
|
145
|
+
transform: (source) => normalized.privacy.profile
|
|
146
|
+
? applyPrivacyProfile(source, normalized.privacy.profile)
|
|
147
|
+
: source
|
|
148
|
+
});
|
|
141
149
|
|
|
142
150
|
const filterEngine = providers.filterEngine ?? createDefaultFilterEngine(normalized.filters);
|
|
143
151
|
assertProvider("filterEngine", filterEngine, ["detect"]);
|
|
144
|
-
const policyEngine = providers.policyEngine ??
|
|
152
|
+
const policyEngine = providers.policyEngine ?? policyProfiles.base.policyEngine;
|
|
145
153
|
assertProvider("policyEngine", policyEngine, ["decide"]);
|
|
146
154
|
|
|
155
|
+
const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider);
|
|
156
|
+
|
|
147
157
|
return {
|
|
148
158
|
config: normalized,
|
|
149
159
|
tokenVault,
|
|
150
160
|
auditSink,
|
|
161
|
+
authProvider,
|
|
162
|
+
policyProfiles,
|
|
151
163
|
protocolAdapter: createProtocolAdapter(normalized.target),
|
|
152
164
|
haechi: createHaechi({
|
|
153
165
|
mode: normalized.mode,
|
|
@@ -212,6 +224,11 @@ export function normalizeConfig(config) {
|
|
|
212
224
|
...defaultConfig().privacy,
|
|
213
225
|
...(config.privacy ?? {})
|
|
214
226
|
},
|
|
227
|
+
auth: {
|
|
228
|
+
...defaultConfig().auth,
|
|
229
|
+
...(config.auth ?? {}),
|
|
230
|
+
allowedLabelKeys: config.auth?.allowedLabelKeys ?? defaultConfig().auth.allowedLabelKeys
|
|
231
|
+
},
|
|
215
232
|
mcp: {
|
|
216
233
|
...defaultConfig().mcp,
|
|
217
234
|
...(config.mcp ?? {}),
|
|
@@ -288,6 +305,17 @@ export function normalizeConfig(config) {
|
|
|
288
305
|
if (typeof merged.limits.upstreamTimeoutMs !== "number" || merged.limits.upstreamTimeoutMs < 1) {
|
|
289
306
|
throw new Error("limits.upstreamTimeoutMs must be a positive number");
|
|
290
307
|
}
|
|
308
|
+
validatePolicyExtras(merged.policy);
|
|
309
|
+
if (!["none", "bearer", "external"].includes(merged.auth.provider)) {
|
|
310
|
+
throw new Error(`Invalid auth.provider: ${merged.auth.provider}`);
|
|
311
|
+
}
|
|
312
|
+
if (typeof merged.auth.store !== "string" || !merged.auth.store.trim()) {
|
|
313
|
+
throw new Error("auth.store must be a non-empty string");
|
|
314
|
+
}
|
|
315
|
+
if (!Array.isArray(merged.auth.allowedLabelKeys)
|
|
316
|
+
|| !merged.auth.allowedLabelKeys.every((key) => typeof key === "string" && key.trim())) {
|
|
317
|
+
throw new Error("auth.allowedLabelKeys must be an array of non-empty strings");
|
|
318
|
+
}
|
|
291
319
|
createProtocolAdapter(merged.target);
|
|
292
320
|
return merged;
|
|
293
321
|
}
|
|
@@ -296,6 +324,76 @@ export function isValidPort(port) {
|
|
|
296
324
|
return Number.isInteger(port) && port >= 0 && port <= 65535;
|
|
297
325
|
}
|
|
298
326
|
|
|
327
|
+
function validatePolicyExtras(policy) {
|
|
328
|
+
if (policy.modelAllowlist !== undefined) {
|
|
329
|
+
assertModelAllowlist(policy.modelAllowlist, "policy.modelAllowlist");
|
|
330
|
+
}
|
|
331
|
+
if (policy.rate !== undefined) {
|
|
332
|
+
assertRate(policy.rate, "policy.rate");
|
|
333
|
+
}
|
|
334
|
+
if (policy.profiles !== undefined) {
|
|
335
|
+
if (typeof policy.profiles !== "object" || policy.profiles === null || Array.isArray(policy.profiles)) {
|
|
336
|
+
throw new Error("policy.profiles must be an object of named profiles");
|
|
337
|
+
}
|
|
338
|
+
for (const [name, profile] of Object.entries(policy.profiles)) {
|
|
339
|
+
if (typeof profile !== "object" || profile === null || Array.isArray(profile)) {
|
|
340
|
+
throw new Error(`policy.profiles.${name} must be an object`);
|
|
341
|
+
}
|
|
342
|
+
if (profile.modelAllowlist !== undefined) {
|
|
343
|
+
assertModelAllowlist(profile.modelAllowlist, `policy.profiles.${name}.modelAllowlist`);
|
|
344
|
+
}
|
|
345
|
+
if (profile.rate !== undefined) {
|
|
346
|
+
assertRate(profile.rate, `policy.profiles.${name}.rate`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (policy.profileBinding !== undefined) {
|
|
351
|
+
const binding = policy.profileBinding;
|
|
352
|
+
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) {
|
|
353
|
+
throw new Error("policy.profileBinding must be an object");
|
|
354
|
+
}
|
|
355
|
+
if (typeof binding.default !== "string" || !binding.default.trim()) {
|
|
356
|
+
throw new Error("policy.profileBinding.default must be a profile name");
|
|
357
|
+
}
|
|
358
|
+
for (const field of ["byScope", "byLabel"]) {
|
|
359
|
+
if (binding[field] !== undefined
|
|
360
|
+
&& (typeof binding[field] !== "object" || binding[field] === null || Array.isArray(binding[field]))) {
|
|
361
|
+
throw new Error(`policy.profileBinding.${field} must be an object`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function assertModelAllowlist(value, label) {
|
|
368
|
+
if (!Array.isArray(value) || !value.every((model) => typeof model === "string" && model.trim())) {
|
|
369
|
+
throw new Error(`${label} must be an array of non-empty strings`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function assertRate(value, label) {
|
|
374
|
+
if (typeof value !== "object" || value === null
|
|
375
|
+
|| typeof value.requestsPerMinute !== "number" || value.requestsPerMinute < 1) {
|
|
376
|
+
throw new Error(`${label}.requestsPerMinute must be a positive number`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function resolveAuthProvider(config, providers, cryptoProvider) {
|
|
381
|
+
if (config.auth.provider === "external") {
|
|
382
|
+
if (typeof providers.authProvider?.authenticate !== "function") {
|
|
383
|
+
throw new Error("auth.provider external requires createRuntime(config, { authProvider })");
|
|
384
|
+
}
|
|
385
|
+
return providers.authProvider;
|
|
386
|
+
}
|
|
387
|
+
if (providers.authProvider) {
|
|
388
|
+
// An injected provider overrides the built-in selection.
|
|
389
|
+
return providers.authProvider;
|
|
390
|
+
}
|
|
391
|
+
if (config.auth.provider === "bearer") {
|
|
392
|
+
return createBearerAuthProvider({ path: config.auth.store, cryptoProvider });
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
299
397
|
function createConfiguredCryptoProvider(config) {
|
|
300
398
|
if (config.keys.provider === "external") {
|
|
301
399
|
throw new Error("keys.provider external requires createRuntime(config, { cryptoProvider })");
|
package/packages/core/index.mjs
CHANGED
|
@@ -7,14 +7,19 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
7
7
|
throw new Error("Haechi requires filterEngine, policyEngine, cryptoProvider, and auditSink");
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
async function protectJson(payload,
|
|
10
|
+
async function protectJson(payload, rawContext = {}) {
|
|
11
|
+
// A per-request policy engine (a named profile selected from identity)
|
|
12
|
+
// overrides the default. It is a control object, NOT data: strip it before
|
|
13
|
+
// anything downstream (tokenize AAD, audit) sees the context.
|
|
14
|
+
const { policyEngine: contextEngine, ...context } = rawContext;
|
|
11
15
|
const effectiveMode = context.mode ?? mode;
|
|
16
|
+
const engine = contextEngine ?? policyEngine;
|
|
12
17
|
const entries = collectStringEntries(payload);
|
|
13
18
|
const detections = await filterEngine.detect({ entries, context });
|
|
14
19
|
const decisions = [];
|
|
15
20
|
|
|
16
21
|
for (const detection of detections) {
|
|
17
|
-
decisions.push(await
|
|
22
|
+
decisions.push(await engine.decide({ detection, context, mode: effectiveMode }));
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
|
|
@@ -55,8 +60,12 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
55
60
|
// Holds a bounded raw tail so a detection split across chunk boundaries is
|
|
56
61
|
// caught before the leading part is emitted. maxMatchBytes bounds the
|
|
57
62
|
// guarantee: a single match longer than it may still split across frames.
|
|
58
|
-
function createStreamProtector(
|
|
63
|
+
function createStreamProtector(rawContext = {}) {
|
|
64
|
+
// Strip the control-object policy engine from the data context (see
|
|
65
|
+
// protectJson) so it cannot leak into tokenize AAD or audit.
|
|
66
|
+
const { policyEngine: contextEngine, ...context } = rawContext;
|
|
59
67
|
const effectiveMode = context.mode ?? mode;
|
|
68
|
+
const engine = contextEngine ?? policyEngine;
|
|
60
69
|
const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
|
|
61
70
|
const maxMatchBytes = context.maxMatchBytes ?? 256;
|
|
62
71
|
const byType = {};
|
|
@@ -76,7 +85,7 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
76
85
|
async function decideAll(detections) {
|
|
77
86
|
const decisions = [];
|
|
78
87
|
for (const detection of detections) {
|
|
79
|
-
decisions.push(await
|
|
88
|
+
decisions.push(await engine.decide({ detection, context, mode: effectiveMode }));
|
|
80
89
|
}
|
|
81
90
|
return decisions;
|
|
82
91
|
}
|
|
@@ -378,9 +387,11 @@ function buildAuditEvent({ context, mode, enforced, blocked, payload, detections
|
|
|
378
387
|
timestamp: new Date().toISOString(),
|
|
379
388
|
protocol: context.protocol ?? "custom",
|
|
380
389
|
operation: context.operation ?? "protect",
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
|
|
390
|
+
// PII-safe identity built by the auth layer (subject/issuer are keyed
|
|
391
|
+
// HMACs); null when no auth is configured. `profile` is the resolved
|
|
392
|
+
// policy profile name (or null).
|
|
393
|
+
identity: context.identity ?? null,
|
|
394
|
+
profile: context.profile ?? null,
|
|
384
395
|
mode,
|
|
385
396
|
enforced,
|
|
386
397
|
blocked,
|
|
@@ -130,6 +130,88 @@ export function createPolicyEngine(policy) {
|
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Compiles the base policy plus every named profile into ready policy engines
|
|
134
|
+
// and a resolver that maps an identity to one. A profile inherits the base
|
|
135
|
+
// policy's presets/actions and overrides on top (so a profile need only state
|
|
136
|
+
// what differs). `transform` (e.g. applyPrivacyProfile) is applied to each
|
|
137
|
+
// compiled policy source before buildPolicy.
|
|
138
|
+
export function createPolicyProfiles(policyConfig = {}, { transform } = {}) {
|
|
139
|
+
const { profiles = {}, profileBinding = null, ...baseSource } = policyConfig;
|
|
140
|
+
const apply = (source) => (transform ? transform(source) : source);
|
|
141
|
+
|
|
142
|
+
const baseEngine = createPolicyEngine(buildPolicy(apply(baseSource)));
|
|
143
|
+
const profileNames = Object.keys(profiles);
|
|
144
|
+
const engines = new Map();
|
|
145
|
+
|
|
146
|
+
for (const name of profileNames) {
|
|
147
|
+
const override = profiles[name] ?? {};
|
|
148
|
+
const merged = {
|
|
149
|
+
...baseSource,
|
|
150
|
+
...override,
|
|
151
|
+
// Profile presets replace the base presets when given; actions merge over
|
|
152
|
+
// the base via buildPolicy's strengthen-only rules.
|
|
153
|
+
actions: { ...(baseSource.actions ?? {}), ...(override.actions ?? {}) },
|
|
154
|
+
modelAllowlist: override.modelAllowlist ?? baseSource.modelAllowlist,
|
|
155
|
+
rate: override.rate ?? baseSource.rate
|
|
156
|
+
};
|
|
157
|
+
engines.set(name, {
|
|
158
|
+
policyEngine: createPolicyEngine(buildPolicy(apply(merged))),
|
|
159
|
+
modelAllowlist: merged.modelAllowlist ?? null,
|
|
160
|
+
rate: merged.rate ?? null
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (profileBinding) {
|
|
165
|
+
if (!profileBinding.default || !engines.has(profileBinding.default)) {
|
|
166
|
+
throw new Error("policy.profileBinding.default must name a declared profile");
|
|
167
|
+
}
|
|
168
|
+
for (const map of [profileBinding.byScope ?? {}, profileBinding.byLabel ?? {}]) {
|
|
169
|
+
for (const [key, target] of Object.entries(map)) {
|
|
170
|
+
if (!engines.has(target)) {
|
|
171
|
+
throw new Error(`policy.profileBinding maps ${key} to unknown profile: ${target}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} else if (profileNames.length > 0) {
|
|
176
|
+
throw new Error("policy.profiles requires policy.profileBinding with a default");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const base = {
|
|
180
|
+
policyEngine: baseEngine,
|
|
181
|
+
modelAllowlist: baseSource.modelAllowlist ?? null,
|
|
182
|
+
rate: baseSource.rate ?? null
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
base,
|
|
187
|
+
hasProfiles: profileNames.length > 0,
|
|
188
|
+
// Resolve identity → { profile, policyEngine, modelAllowlist, rate }.
|
|
189
|
+
// Order: scope match → label match → default. Without profiles or identity,
|
|
190
|
+
// the base policy applies.
|
|
191
|
+
resolve(identity) {
|
|
192
|
+
if (!profileBinding) {
|
|
193
|
+
return { profile: null, ...base };
|
|
194
|
+
}
|
|
195
|
+
if (identity) {
|
|
196
|
+
for (const scope of identity.scopes ?? []) {
|
|
197
|
+
const name = profileBinding.byScope?.[scope];
|
|
198
|
+
if (name) {
|
|
199
|
+
return { profile: name, ...engines.get(name) };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const [key, value] of Object.entries(identity.labels ?? {})) {
|
|
203
|
+
const name = profileBinding.byLabel?.[`${key}=${value}`];
|
|
204
|
+
if (name) {
|
|
205
|
+
return { profile: name, ...engines.get(name) };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const fallback = profileBinding.default;
|
|
210
|
+
return { profile: fallback, ...engines.get(fallback) };
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
133
215
|
export function validatePolicy(policy) {
|
|
134
216
|
if (!policy || typeof policy !== "object") {
|
|
135
217
|
throw new Error("Policy must be an object");
|