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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haechi",
3
- "version": "0.5.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",
@@ -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
- integrity: "sha256-hash-chain"
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
- // headHash anchors the chain externally: publishing it out-of-band is the
131
- // only defense against tail truncation, which the chain alone cannot detect.
132
- return { valid: true, records, headHash: expectedPreviousHash };
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
- if (!auditPath) {
150
+ let anchorPath = typeof options.anchor === "string" ? options.anchor : null;
151
+ if (!auditPath || (options.anchor === true && !anchorPath)) {
146
152
  try {
147
- auditPath = (await loadConfig(options.config ?? DEFAULT_CONFIG_PATH)).audit.path;
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 audit = { path: config.audit.path, exists: false, chain: null };
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
- if (!next || next.startsWith("--")) {
446
- options[key] = true;
447
- continue;
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. The head hash is the value to anchor externally against tail truncation."
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