haechi 0.5.0 → 0.7.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 +42 -0
- package/README.md +42 -0
- package/docs/README.md +2 -0
- package/docs/current/api-stability.ko.md +6 -1
- package/docs/current/api-stability.md +6 -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/release-0.7-implementation-scope.ko.md +91 -0
- package/docs/current/release-0.7-implementation-scope.md +92 -0
- package/docs/current/release-process.ko.md +26 -3
- package/docs/current/release-process.md +26 -3
- package/docs/current/risk-register-release-gate.ko.md +3 -3
- package/docs/current/risk-register-release-gate.md +4 -3
- package/docs/current/threat-model.ko.md +7 -2
- package/docs/current/threat-model.md +7 -2
- package/examples/crypto-kms-reference/README.md +47 -0
- package/examples/crypto-kms-reference/index.mjs +133 -0
- package/examples/crypto-kms-reference/package.json +19 -0
- package/haechi.config.example.json +16 -1
- package/package.json +5 -2
- package/packages/audit/index.mjs +99 -6
- package/packages/auth/index.mjs +170 -0
- package/packages/cli/bin/haechi.mjs +131 -14
- package/packages/cli/runtime.mjs +136 -8
- package/packages/core/index.mjs +18 -7
- package/packages/crypto/index.mjs +107 -0
- package/packages/policy/index.mjs +82 -0
- package/packages/proxy/index.mjs +134 -8
- package/scripts/release-checksums.mjs +100 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haechi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
"haechi.config.example.json",
|
|
55
56
|
"packages/",
|
|
56
57
|
"examples/",
|
|
58
|
+
"scripts/release-checksums.mjs",
|
|
57
59
|
"docs/current/"
|
|
58
60
|
],
|
|
59
61
|
"scripts": {
|
|
@@ -62,6 +64,7 @@
|
|
|
62
64
|
"pack:dry": "npm pack --dry-run",
|
|
63
65
|
"scan:stale-names": "node scripts/stale-name-scan.mjs",
|
|
64
66
|
"sbom": "node scripts/generate-sbom.mjs",
|
|
67
|
+
"checksums": "node scripts/release-checksums.mjs",
|
|
65
68
|
"bench:payload": "node scripts/bench-payload.mjs",
|
|
66
69
|
"release:preflight": "node scripts/release-preflight.mjs",
|
|
67
70
|
"release:preflight:npm": "node scripts/release-preflight.mjs --require-npm-auth",
|
package/packages/audit/index.mjs
CHANGED
|
@@ -7,20 +7,55 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
7
7
|
|
|
8
8
|
const FORBIDDEN_KEYS = new Set(["value", "plaintext", "payload", "content", "message", "prompt", "secret"]);
|
|
9
9
|
|
|
10
|
-
export function createJsonlAuditSink({ path }) {
|
|
10
|
+
export function createJsonlAuditSink({ path, anchor = null }) {
|
|
11
11
|
if (!path) {
|
|
12
12
|
throw new Error("JSONL audit sink requires path");
|
|
13
13
|
}
|
|
14
|
+
const anchorMode = anchor?.mode ?? "none";
|
|
15
|
+
const anchorPath = anchor?.path ?? null;
|
|
16
|
+
const everyRecords = anchor?.everyRecords ?? 1;
|
|
17
|
+
if (!["none", "file", "stdout"].includes(anchorMode)) {
|
|
18
|
+
throw new Error(`Invalid audit anchor mode: ${anchorMode}`);
|
|
19
|
+
}
|
|
20
|
+
if (anchorMode === "file" && !anchorPath) {
|
|
21
|
+
throw new Error("audit anchor mode 'file' requires an anchor path");
|
|
22
|
+
}
|
|
23
|
+
// The sink is a public export reachable via auditSink injection, so it
|
|
24
|
+
// validates everyRecords itself rather than trusting normalizeConfig.
|
|
25
|
+
if (!Number.isInteger(everyRecords) || everyRecords < 1) {
|
|
26
|
+
throw new Error("audit anchor everyRecords must be a positive integer");
|
|
27
|
+
}
|
|
14
28
|
|
|
15
29
|
let writeQueue = Promise.resolve();
|
|
16
30
|
|
|
31
|
+
async function writeAnchor(record) {
|
|
32
|
+
const { sequence, eventHash } = record.auditIntegrity;
|
|
33
|
+
// Tamper-evidence against tail truncation: the chain head is appended to a
|
|
34
|
+
// separate append-only stream, so deleting trailing records leaves the
|
|
35
|
+
// chain shorter than the last anchored sequence.
|
|
36
|
+
if (anchorMode === "none" || sequence % everyRecords !== 0) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const line = `${JSON.stringify({ sequence, eventHash, timestamp: record.timestamp })}\n`;
|
|
40
|
+
if (anchorMode === "stdout") {
|
|
41
|
+
process.stdout.write(line);
|
|
42
|
+
} else {
|
|
43
|
+
await mkdir(dirname(anchorPath), { recursive: true });
|
|
44
|
+
// 0600 on creation, like the key/lock files. Note this only matters for
|
|
45
|
+
// confidentiality of the timeline — tamper-evidence still requires the
|
|
46
|
+
// anchor to live on append-only/separate media (see docs).
|
|
47
|
+
await appendFile(anchorPath, line, { mode: 0o600 });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
17
51
|
return {
|
|
18
52
|
id: "haechi.audit.jsonl",
|
|
19
53
|
version: "0.1.0",
|
|
20
54
|
capabilities: {
|
|
21
55
|
writesAudit: true,
|
|
22
56
|
writesPlaintext: false,
|
|
23
|
-
|
|
57
|
+
appendOnly: true,
|
|
58
|
+
integrity: anchorMode === "none" ? "sha256-hash-chain" : "sha256-hash-chain+anchor"
|
|
24
59
|
},
|
|
25
60
|
async record(event) {
|
|
26
61
|
const write = writeQueue.then(async () => {
|
|
@@ -28,6 +63,7 @@ export function createJsonlAuditSink({ path }) {
|
|
|
28
63
|
await withFileLock(`${path}.lock`, async () => {
|
|
29
64
|
const record = await buildIntegrityRecord(path, sanitizeAudit(event));
|
|
30
65
|
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
66
|
+
await writeAnchor(record);
|
|
31
67
|
});
|
|
32
68
|
});
|
|
33
69
|
writeQueue = write.catch(() => {});
|
|
@@ -87,7 +123,12 @@ export function sanitizeAudit(value) {
|
|
|
87
123
|
return value;
|
|
88
124
|
}
|
|
89
125
|
|
|
90
|
-
export async function verifyAuditChain(path) {
|
|
126
|
+
export async function verifyAuditChain(path, { anchorPath = null } = {}) {
|
|
127
|
+
// The anchor stream (if provided) records the chain head at past points; a
|
|
128
|
+
// chain shorter than the last anchor, or a hash that disagrees with an
|
|
129
|
+
// anchor, is tail truncation / tampering the chain alone cannot catch.
|
|
130
|
+
const anchors = anchorPath ? await readAnchors(anchorPath) : null;
|
|
131
|
+
|
|
91
132
|
const lines = createInterface({
|
|
92
133
|
input: createReadStream(path, { encoding: "utf8" }),
|
|
93
134
|
crlfDelay: Infinity
|
|
@@ -122,14 +163,66 @@ export async function verifyAuditChain(path) {
|
|
|
122
163
|
return { valid: false, records, reason: "event hash mismatch" };
|
|
123
164
|
}
|
|
124
165
|
|
|
166
|
+
if (anchors && anchors.bySequence.has(expectedSequence)
|
|
167
|
+
&& anchors.bySequence.get(expectedSequence) !== eventHash) {
|
|
168
|
+
return { valid: false, records, reason: `anchor hash mismatch at sequence ${expectedSequence}` };
|
|
169
|
+
}
|
|
170
|
+
|
|
125
171
|
expectedPreviousHash = eventHash;
|
|
126
172
|
expectedSequence += 1;
|
|
127
173
|
records += 1;
|
|
128
174
|
}
|
|
129
175
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
176
|
+
if (anchors && anchors.lastSequence > records) {
|
|
177
|
+
return {
|
|
178
|
+
valid: false,
|
|
179
|
+
records,
|
|
180
|
+
reason: `tail truncation: chain has ${records} records but anchor attests sequence ${anchors.lastSequence}`
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// headHash anchors the chain externally. With anchorPath, truncation back to
|
|
185
|
+
// the last anchor is now detected; the residual gap is records written after
|
|
186
|
+
// the last anchor.
|
|
187
|
+
const result = { valid: true, records, headHash: expectedPreviousHash };
|
|
188
|
+
if (anchors) {
|
|
189
|
+
result.anchored = { count: anchors.bySequence.size, lastSequence: anchors.lastSequence };
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function readAnchors(anchorPath) {
|
|
195
|
+
const bySequence = new Map();
|
|
196
|
+
let lastSequence = 0;
|
|
197
|
+
try {
|
|
198
|
+
const lines = createInterface({
|
|
199
|
+
input: createReadStream(anchorPath, { encoding: "utf8" }),
|
|
200
|
+
crlfDelay: Infinity
|
|
201
|
+
});
|
|
202
|
+
for await (const line of lines) {
|
|
203
|
+
if (!line.trim()) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
// A crash can leave a partial trailing anchor line; tolerate it (skip)
|
|
207
|
+
// rather than failing the whole verification. The chain check plus the
|
|
208
|
+
// remaining valid anchors still bound truncation detection.
|
|
209
|
+
let anchor;
|
|
210
|
+
try {
|
|
211
|
+
anchor = JSON.parse(line);
|
|
212
|
+
} catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (typeof anchor.sequence === "number" && typeof anchor.eventHash === "string") {
|
|
216
|
+
bySequence.set(anchor.sequence, anchor.eventHash);
|
|
217
|
+
lastSequence = Math.max(lastSequence, anchor.sequence);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error.code !== "ENOENT") {
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { bySequence, lastSequence };
|
|
133
226
|
}
|
|
134
227
|
|
|
135
228
|
async function buildIntegrityRecord(path, event) {
|
|
@@ -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;
|
|
@@ -142,19 +147,27 @@ async function reportCommand(argv) {
|
|
|
142
147
|
async function auditVerifyCommand(argv) {
|
|
143
148
|
const options = parseOptions(argv);
|
|
144
149
|
let auditPath = options.audit ?? options.path;
|
|
145
|
-
|
|
150
|
+
let anchorPath = typeof options.anchor === "string" ? options.anchor : null;
|
|
151
|
+
if (!auditPath || (options.anchor === true && !anchorPath)) {
|
|
146
152
|
try {
|
|
147
|
-
|
|
153
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
154
|
+
auditPath = auditPath ?? config.audit.path;
|
|
155
|
+
// --anchor with no value (or no flag at all) falls back to the configured
|
|
156
|
+
// anchor path when anchoring is enabled.
|
|
157
|
+
if (!anchorPath && config.audit.anchor.mode === "file") {
|
|
158
|
+
anchorPath = config.audit.anchor.path;
|
|
159
|
+
}
|
|
148
160
|
} catch {
|
|
149
|
-
auditPath = ".haechi/audit.jsonl";
|
|
161
|
+
auditPath = auditPath ?? ".haechi/audit.jsonl";
|
|
150
162
|
}
|
|
151
163
|
}
|
|
152
164
|
|
|
153
|
-
const result = await verifyAuditChain(auditPath);
|
|
165
|
+
const result = await verifyAuditChain(auditPath, { anchorPath });
|
|
154
166
|
writeJson({
|
|
155
167
|
ok: result.valid,
|
|
156
168
|
command: "audit-verify",
|
|
157
169
|
auditPath,
|
|
170
|
+
anchorPath,
|
|
158
171
|
result
|
|
159
172
|
});
|
|
160
173
|
if (!result.valid) {
|
|
@@ -203,17 +216,30 @@ async function statusCommand(argv) {
|
|
|
203
216
|
warnings.push(`key file ${config.keys.keyFile} does not exist; run haechi init`);
|
|
204
217
|
}
|
|
205
218
|
|
|
206
|
-
const
|
|
219
|
+
const anchorEnabled = config.audit.anchor.mode === "file";
|
|
220
|
+
const audit = {
|
|
221
|
+
path: config.audit.path,
|
|
222
|
+
exists: false,
|
|
223
|
+
chain: null,
|
|
224
|
+
anchor: { mode: config.audit.anchor.mode, path: anchorEnabled ? config.audit.anchor.path : null }
|
|
225
|
+
};
|
|
207
226
|
try {
|
|
208
227
|
await stat(config.audit.path);
|
|
209
228
|
audit.exists = true;
|
|
210
|
-
audit.chain = await verifyAuditChain(config.audit.path
|
|
229
|
+
audit.chain = await verifyAuditChain(config.audit.path, {
|
|
230
|
+
anchorPath: anchorEnabled ? config.audit.anchor.path : null
|
|
231
|
+
});
|
|
211
232
|
if (!audit.chain.valid) {
|
|
212
233
|
warnings.push(`audit chain verification failed: ${audit.chain.reason}`);
|
|
213
234
|
}
|
|
214
235
|
} catch {
|
|
215
236
|
// No audit file yet is a normal pre-first-run state, not a warning.
|
|
216
237
|
}
|
|
238
|
+
if (config.audit.anchor.mode === "none") {
|
|
239
|
+
warnings.push("audit.anchor.mode is none: tail truncation of the audit log cannot be detected");
|
|
240
|
+
} else if (config.audit.anchor.mode === "file") {
|
|
241
|
+
warnings.push("audit.anchor: real tail-truncation defense requires the anchor on append-only or separate media; on the same writable filesystem an attacker can truncate both files together");
|
|
242
|
+
}
|
|
217
243
|
|
|
218
244
|
writeJson({
|
|
219
245
|
ok: true,
|
|
@@ -402,6 +428,76 @@ async function pluginValidateCommand(argv) {
|
|
|
402
428
|
}
|
|
403
429
|
}
|
|
404
430
|
|
|
431
|
+
async function authCommand(argv) {
|
|
432
|
+
const [sub, ...rest] = argv;
|
|
433
|
+
const options = parseOptions(rest);
|
|
434
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
435
|
+
if (config.keys.provider !== "local") {
|
|
436
|
+
throw new Error("haechi auth requires keys.provider local (the bearer store is hashed with the local key)");
|
|
437
|
+
}
|
|
438
|
+
const cryptoProvider = createLocalCryptoProvider({ keyFile: config.keys.keyFile });
|
|
439
|
+
const storePath = config.auth.store;
|
|
440
|
+
|
|
441
|
+
switch (sub) {
|
|
442
|
+
case "add": {
|
|
443
|
+
if (!options.type || options.type === true) {
|
|
444
|
+
throw new Error("auth add requires --type user|service|agent");
|
|
445
|
+
}
|
|
446
|
+
const { token, record } = await addToken({
|
|
447
|
+
path: storePath,
|
|
448
|
+
cryptoProvider,
|
|
449
|
+
type: options.type,
|
|
450
|
+
scopes: asList(options.scope),
|
|
451
|
+
labels: asLabels(options.label),
|
|
452
|
+
allowedLabelKeys: config.auth.allowedLabelKeys
|
|
453
|
+
});
|
|
454
|
+
writeJson({
|
|
455
|
+
ok: true,
|
|
456
|
+
command: "auth add",
|
|
457
|
+
id: record.id,
|
|
458
|
+
type: record.type,
|
|
459
|
+
scopes: record.scopes,
|
|
460
|
+
labels: record.labels,
|
|
461
|
+
token,
|
|
462
|
+
warning: "This token is shown only once. Store it now; it is not recoverable."
|
|
463
|
+
});
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
case "list":
|
|
467
|
+
writeJson({ ok: true, command: "auth list", tokens: await listTokens(storePath) });
|
|
468
|
+
return;
|
|
469
|
+
case "revoke": {
|
|
470
|
+
const [id] = rest;
|
|
471
|
+
if (!id || id.startsWith("--")) {
|
|
472
|
+
throw new Error("auth revoke requires a token id");
|
|
473
|
+
}
|
|
474
|
+
writeJson({ ok: true, command: "auth revoke", result: await revokeToken({ path: storePath, id }) });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
default:
|
|
478
|
+
throw new Error("auth requires a subcommand: add | list | revoke");
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function asList(value) {
|
|
483
|
+
if (!value || value === true) {
|
|
484
|
+
return [];
|
|
485
|
+
}
|
|
486
|
+
return Array.isArray(value) ? value : [value];
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function asLabels(value) {
|
|
490
|
+
const labels = {};
|
|
491
|
+
for (const entry of asList(value)) {
|
|
492
|
+
const index = entry.indexOf("=");
|
|
493
|
+
if (index === -1) {
|
|
494
|
+
throw new Error(`Invalid --label (expected key=value): ${entry}`);
|
|
495
|
+
}
|
|
496
|
+
labels[entry.slice(0, index)] = entry.slice(index + 1);
|
|
497
|
+
}
|
|
498
|
+
return labels;
|
|
499
|
+
}
|
|
500
|
+
|
|
405
501
|
async function mcpStdioCommand(argv) {
|
|
406
502
|
const options = parseOptions(argv);
|
|
407
503
|
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
@@ -442,12 +538,17 @@ function parseOptions(argv) {
|
|
|
442
538
|
}
|
|
443
539
|
const key = arg.slice(2);
|
|
444
540
|
const next = argv[index + 1];
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
541
|
+
const value = (!next || next.startsWith("--")) ? true : next;
|
|
542
|
+
if (value !== true) {
|
|
543
|
+
index += 1;
|
|
544
|
+
}
|
|
545
|
+
// Repeated flags accumulate into an array (e.g. --scope a --scope b);
|
|
546
|
+
// a single occurrence stays scalar for backward compatibility.
|
|
547
|
+
if (Object.prototype.hasOwnProperty.call(options, key)) {
|
|
548
|
+
options[key] = Array.isArray(options[key]) ? [...options[key], value] : [options[key], value];
|
|
549
|
+
} else {
|
|
550
|
+
options[key] = value;
|
|
448
551
|
}
|
|
449
|
-
options[key] = next;
|
|
450
|
-
index += 1;
|
|
451
552
|
}
|
|
452
553
|
return options;
|
|
453
554
|
}
|
|
@@ -486,9 +587,9 @@ const COMMAND_HELP = {
|
|
|
486
587
|
summary: "Summarize audit events without raw payloads."
|
|
487
588
|
},
|
|
488
589
|
"audit-verify": {
|
|
489
|
-
usage: "haechi audit-verify [--audit .haechi/audit.jsonl] [--config haechi.config.json]",
|
|
590
|
+
usage: "haechi audit-verify [--audit .haechi/audit.jsonl] [--anchor [path]] [--config haechi.config.json]",
|
|
490
591
|
summary: "Verify the audit hash chain; print validity, record count, and head hash.",
|
|
491
|
-
detail: "Exit 4 on a broken chain.
|
|
592
|
+
detail: "Exit 4 on a broken chain. With --anchor (or audit.anchor.mode: file in config) it cross-checks the anchor stream and detects tail truncation back to the last anchor. The anchor only adds real defense when kept on append-only or separate media — on the same writable filesystem an attacker can truncate both files together."
|
|
492
593
|
},
|
|
493
594
|
status: {
|
|
494
595
|
usage: "haechi status [--config haechi.config.json]",
|
|
@@ -534,6 +635,11 @@ const COMMAND_HELP = {
|
|
|
534
635
|
summary: "Wrap an MCP server with bidirectional stdio protection.",
|
|
535
636
|
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
637
|
},
|
|
638
|
+
auth: {
|
|
639
|
+
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]",
|
|
640
|
+
summary: "Manage built-in bearer tokens (separate store, hashed).",
|
|
641
|
+
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."
|
|
642
|
+
},
|
|
537
643
|
config: {
|
|
538
644
|
usage: "haechi config",
|
|
539
645
|
summary: "Print the configuration guide (keys, defaults, common setups)."
|
|
@@ -551,7 +657,7 @@ function printHelp(topic) {
|
|
|
551
657
|
"init", "protect", "report", "status", "audit-verify", "proxy",
|
|
552
658
|
"policy-sign", "policy-verify",
|
|
553
659
|
"token-reveal", "token-purge", "token-export",
|
|
554
|
-
"plugin-validate", "mcp-stdio", "mcp-wrap", "config"
|
|
660
|
+
"plugin-validate", "mcp-stdio", "mcp-wrap", "auth", "config"
|
|
555
661
|
];
|
|
556
662
|
const lines = order.map((name) => ` ${name.padEnd(16)}${COMMAND_HELP[name].summary}`);
|
|
557
663
|
console.log(`Haechi — self-hosted AI context enforcement (developer preview)
|
|
@@ -613,6 +719,17 @@ Tokenization (model sees token, caller sees plaintext)
|
|
|
613
719
|
tokenVault.detokenizeResponses restore request-issued tokens in the response
|
|
614
720
|
(needs responseProtection.enabled)
|
|
615
721
|
|
|
722
|
+
Audit integrity
|
|
723
|
+
audit.anchor.mode none | file | stdout (default none)
|
|
724
|
+
file/stdout anchor the chain head so tail
|
|
725
|
+
truncation is detected (haechi audit-verify --anchor).
|
|
726
|
+
Real defense needs the anchor on append-only or
|
|
727
|
+
separate media; same-filesystem anchors can be
|
|
728
|
+
truncated together. stdout mode is for long-running
|
|
729
|
+
commands (proxy), not JSON-emitting ones.
|
|
730
|
+
audit.anchor.path .haechi/audit.anchor.jsonl (mode: file)
|
|
731
|
+
audit.anchor.everyRecords anchor cadence (default 1)
|
|
732
|
+
|
|
616
733
|
Privacy + MCP
|
|
617
734
|
privacy.profile kr-pipa | eu-gdpr | us-general | null
|
|
618
735
|
mcp.allowedMethods client-callable method allowlist
|