haechi 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +154 -0
- package/README.md +102 -0
- package/SECURITY.md +31 -0
- package/docs/README.md +35 -0
- package/docs/current/api-stability.ko.md +48 -0
- package/docs/current/api-stability.md +48 -0
- package/docs/current/expert-gap-review-ai-llm-mcp-encryption.ko.md +107 -0
- package/docs/current/expert-gap-review-ai-llm-mcp-encryption.md +107 -0
- package/docs/current/global-privacy-compliance-review.ko.md +110 -0
- package/docs/current/global-privacy-compliance-review.md +110 -0
- package/docs/current/initial-plan-ai-llm-mcp-encryption.ko.md +214 -0
- package/docs/current/initial-plan-ai-llm-mcp-encryption.md +214 -0
- package/docs/current/mvp-0.1-implementation-scope.ko.md +79 -0
- package/docs/current/mvp-0.1-implementation-scope.md +79 -0
- package/docs/current/open-source-modular-architecture.ko.md +387 -0
- package/docs/current/open-source-modular-architecture.md +387 -0
- package/docs/current/prd-ai-llm-mcp-encryption.ko.md +260 -0
- package/docs/current/prd-ai-llm-mcp-encryption.md +262 -0
- package/docs/current/privacy-filtering-policy-draft.ko.md +307 -0
- package/docs/current/privacy-filtering-policy-draft.md +307 -0
- package/docs/current/release-0.2-implementation-scope.ko.md +46 -0
- package/docs/current/release-0.2-implementation-scope.md +46 -0
- package/docs/current/release-0.3-implementation-scope.ko.md +86 -0
- package/docs/current/release-0.3-implementation-scope.md +86 -0
- package/docs/current/release-0.3.2-hardening-scope.ko.md +64 -0
- package/docs/current/release-0.3.2-hardening-scope.md +64 -0
- package/docs/current/release-0.4-implementation-scope.ko.md +121 -0
- package/docs/current/release-0.4-implementation-scope.md +121 -0
- package/docs/current/release-process.ko.md +48 -0
- package/docs/current/release-process.md +48 -0
- package/docs/current/risk-register-release-gate.ko.md +154 -0
- package/docs/current/risk-register-release-gate.md +154 -0
- package/docs/current/shared-responsibility.ko.md +38 -0
- package/docs/current/shared-responsibility.md +38 -0
- package/docs/current/threat-model.ko.md +68 -0
- package/docs/current/threat-model.md +68 -0
- package/examples/llm-prompt-filtering/input.json +13 -0
- package/examples/plugins/custom-filter.plugin.json +29 -0
- package/haechi.config.example.json +70 -0
- package/package.json +74 -0
- package/packages/audit/index.mjs +262 -0
- package/packages/cli/bin/haechi.mjs +341 -0
- package/packages/cli/runtime.mjs +287 -0
- package/packages/core/index.mjs +309 -0
- package/packages/crypto/index.mjs +142 -0
- package/packages/filter/index.mjs +189 -0
- package/packages/mcp-stdio/index.mjs +105 -0
- package/packages/plugin/index.mjs +83 -0
- package/packages/policy/index.mjs +165 -0
- package/packages/policy-bundle/index.mjs +91 -0
- package/packages/privacy-profiles/index.mjs +92 -0
- package/packages/protocol-adapters/index.mjs +111 -0
- package/packages/proxy/index.mjs +534 -0
- package/packages/token-vault/index.mjs +262 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { appendFile, mkdir, open, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
|
|
8
|
+
const FORBIDDEN_KEYS = new Set(["value", "plaintext", "payload", "content", "message", "prompt", "secret"]);
|
|
9
|
+
|
|
10
|
+
export function createJsonlAuditSink({ path }) {
|
|
11
|
+
if (!path) {
|
|
12
|
+
throw new Error("JSONL audit sink requires path");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let writeQueue = Promise.resolve();
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
id: "haechi.audit.jsonl",
|
|
19
|
+
version: "0.1.0",
|
|
20
|
+
capabilities: {
|
|
21
|
+
writesAudit: true,
|
|
22
|
+
writesPlaintext: false,
|
|
23
|
+
integrity: "sha256-hash-chain"
|
|
24
|
+
},
|
|
25
|
+
async record(event) {
|
|
26
|
+
const write = writeQueue.then(async () => {
|
|
27
|
+
await mkdir(dirname(path), { recursive: true });
|
|
28
|
+
await withFileLock(`${path}.lock`, async () => {
|
|
29
|
+
const record = await buildIntegrityRecord(path, sanitizeAudit(event));
|
|
30
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
writeQueue = write.catch(() => {});
|
|
34
|
+
await write;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function readAuditSummary(path) {
|
|
40
|
+
const summary = {
|
|
41
|
+
events: 0,
|
|
42
|
+
blocked: 0,
|
|
43
|
+
detections: 0,
|
|
44
|
+
byType: {},
|
|
45
|
+
byAction: {}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const lines = createInterface({
|
|
50
|
+
input: createReadStream(path, { encoding: "utf8" }),
|
|
51
|
+
crlfDelay: Infinity
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
for await (const line of lines) {
|
|
55
|
+
if (!line.trim()) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const event = JSON.parse(line);
|
|
59
|
+
summary.events += 1;
|
|
60
|
+
if (event.blocked) {
|
|
61
|
+
summary.blocked += 1;
|
|
62
|
+
}
|
|
63
|
+
summary.detections += event.summary?.detectionCount ?? 0;
|
|
64
|
+
mergeCounts(summary.byType, event.summary?.byType);
|
|
65
|
+
mergeCounts(summary.byAction, event.summary?.byAction);
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code !== "ENOENT") {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return summary;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function sanitizeAudit(value) {
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
return value.map((item) => sanitizeAudit(item));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (value && typeof value === "object") {
|
|
82
|
+
return Object.fromEntries(Object.entries(value)
|
|
83
|
+
.filter(([key]) => !FORBIDDEN_KEYS.has(key))
|
|
84
|
+
.map(([key, item]) => [key, sanitizeAudit(item)]));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function verifyAuditChain(path) {
|
|
91
|
+
const lines = createInterface({
|
|
92
|
+
input: createReadStream(path, { encoding: "utf8" }),
|
|
93
|
+
crlfDelay: Infinity
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
let expectedPreviousHash = null;
|
|
97
|
+
let expectedSequence = 1;
|
|
98
|
+
let records = 0;
|
|
99
|
+
|
|
100
|
+
for await (const line of lines) {
|
|
101
|
+
if (!line.trim()) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const record = JSON.parse(line);
|
|
105
|
+
const integrity = record.auditIntegrity;
|
|
106
|
+
if (!integrity) {
|
|
107
|
+
return { valid: false, records, reason: "missing auditIntegrity" };
|
|
108
|
+
}
|
|
109
|
+
if (integrity.sequence !== expectedSequence) {
|
|
110
|
+
return { valid: false, records, reason: "sequence mismatch" };
|
|
111
|
+
}
|
|
112
|
+
if ((integrity.previousHash ?? null) !== expectedPreviousHash) {
|
|
113
|
+
return { valid: false, records, reason: "previous hash mismatch" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const { eventHash, ...unsignedIntegrity } = integrity;
|
|
117
|
+
const expectedHash = sha256(canonicalize({
|
|
118
|
+
...record,
|
|
119
|
+
auditIntegrity: unsignedIntegrity
|
|
120
|
+
}));
|
|
121
|
+
if (eventHash !== expectedHash) {
|
|
122
|
+
return { valid: false, records, reason: "event hash mismatch" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
expectedPreviousHash = eventHash;
|
|
126
|
+
expectedSequence += 1;
|
|
127
|
+
records += 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { valid: true, records };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function buildIntegrityRecord(path, event) {
|
|
134
|
+
const previous = await readLastIntegrity(path);
|
|
135
|
+
const sequence = previous ? previous.sequence + 1 : 1;
|
|
136
|
+
const unsigned = {
|
|
137
|
+
...event,
|
|
138
|
+
auditIntegrity: {
|
|
139
|
+
alg: "sha256",
|
|
140
|
+
canonicalization: "json-stable-v1",
|
|
141
|
+
sequence,
|
|
142
|
+
previousHash: previous?.eventHash ?? null
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
...unsigned,
|
|
148
|
+
auditIntegrity: {
|
|
149
|
+
...unsigned.auditIntegrity,
|
|
150
|
+
eventHash: sha256(canonicalize(unsigned))
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Reads only the tail of the audit file so chained appends stay O(1) instead
|
|
156
|
+
// of re-reading the whole log on every record.
|
|
157
|
+
async function readLastIntegrity(path) {
|
|
158
|
+
let handle;
|
|
159
|
+
try {
|
|
160
|
+
handle = await open(path, "r");
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error.code === "ENOENT") {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const { size } = await handle.stat();
|
|
170
|
+
if (size === 0) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let chunkSize = 65536;
|
|
175
|
+
while (true) {
|
|
176
|
+
const start = Math.max(0, size - chunkSize);
|
|
177
|
+
const length = size - start;
|
|
178
|
+
const buffer = Buffer.alloc(length);
|
|
179
|
+
await handle.read(buffer, 0, length, start);
|
|
180
|
+
const lines = buffer.toString("utf8").split(/\r?\n/).filter((line) => line.trim());
|
|
181
|
+
|
|
182
|
+
// The last line is only known to be complete when the chunk covers the
|
|
183
|
+
// whole file or contains a newline before it.
|
|
184
|
+
if (lines.length > 0 && (start === 0 || lines.length > 1)) {
|
|
185
|
+
const last = JSON.parse(lines.at(-1));
|
|
186
|
+
return last.auditIntegrity ?? null;
|
|
187
|
+
}
|
|
188
|
+
if (start === 0) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
chunkSize *= 2;
|
|
192
|
+
}
|
|
193
|
+
} finally {
|
|
194
|
+
await handle.close();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function mergeCounts(target, source = {}) {
|
|
199
|
+
for (const [key, count] of Object.entries(source)) {
|
|
200
|
+
target[key] = (target[key] ?? 0) + count;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function canonicalize(value) {
|
|
205
|
+
if (Array.isArray(value)) {
|
|
206
|
+
return `[${value.map((item) => canonicalize(item)).join(",")}]`;
|
|
207
|
+
}
|
|
208
|
+
if (value && typeof value === "object") {
|
|
209
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(",")}}`;
|
|
210
|
+
}
|
|
211
|
+
return JSON.stringify(value);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sha256(value) {
|
|
215
|
+
return createHash("sha256").update(value).digest("base64url");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function withFileLock(lockPath, operation) {
|
|
219
|
+
const handle = await acquireLock(lockPath);
|
|
220
|
+
try {
|
|
221
|
+
return await operation();
|
|
222
|
+
} finally {
|
|
223
|
+
await handle.close();
|
|
224
|
+
await unlink(lockPath).catch((error) => {
|
|
225
|
+
if (error.code !== "ENOENT") {
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const STALE_LOCK_MS = 30000;
|
|
233
|
+
|
|
234
|
+
async function acquireLock(lockPath) {
|
|
235
|
+
const deadline = Date.now() + 5000;
|
|
236
|
+
while (true) {
|
|
237
|
+
try {
|
|
238
|
+
return await open(lockPath, "wx", 0o600);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error.code !== "EEXIST") {
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
if (await isStaleLock(lockPath)) {
|
|
244
|
+
await unlink(lockPath).catch(() => {});
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (Date.now() > deadline) {
|
|
248
|
+
throw new Error(`Timed out acquiring audit lock: ${lockPath}`);
|
|
249
|
+
}
|
|
250
|
+
await delay(10);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function isStaleLock(lockPath) {
|
|
256
|
+
try {
|
|
257
|
+
const info = await stat(lockPath);
|
|
258
|
+
return Date.now() - info.mtimeMs > STALE_LOCK_MS;
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { readAuditSummary } from "../../audit/index.mjs";
|
|
4
|
+
import { DEFAULT_PROXY_PORT, createHaechiProxy } from "../../proxy/index.mjs";
|
|
5
|
+
import { signPolicyBundleFile, verifyPolicyBundleFile } from "../../policy-bundle/index.mjs";
|
|
6
|
+
import { validatePluginManifestFile } from "../../plugin/index.mjs";
|
|
7
|
+
import { runMcpStdioFilter } from "../../mcp-stdio/index.mjs";
|
|
8
|
+
import { DEFAULT_CONFIG_PATH, createRuntime, isValidPort, loadConfig, writeDefaultConfig } from "../runtime.mjs";
|
|
9
|
+
|
|
10
|
+
const [command, ...argv] = process.argv.slice(2);
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
switch (command) {
|
|
14
|
+
case "init":
|
|
15
|
+
await initCommand(argv);
|
|
16
|
+
break;
|
|
17
|
+
case "protect":
|
|
18
|
+
await protectCommand(argv);
|
|
19
|
+
break;
|
|
20
|
+
case "report":
|
|
21
|
+
await reportCommand(argv);
|
|
22
|
+
break;
|
|
23
|
+
case "proxy":
|
|
24
|
+
await proxyCommand(argv);
|
|
25
|
+
break;
|
|
26
|
+
case "policy-sign":
|
|
27
|
+
await policySignCommand(argv);
|
|
28
|
+
break;
|
|
29
|
+
case "policy-verify":
|
|
30
|
+
await policyVerifyCommand(argv);
|
|
31
|
+
break;
|
|
32
|
+
case "token-reveal":
|
|
33
|
+
await tokenRevealCommand(argv);
|
|
34
|
+
break;
|
|
35
|
+
case "token-purge":
|
|
36
|
+
await tokenPurgeCommand(argv);
|
|
37
|
+
break;
|
|
38
|
+
case "token-export":
|
|
39
|
+
await tokenExportCommand(argv);
|
|
40
|
+
break;
|
|
41
|
+
case "plugin-validate":
|
|
42
|
+
await pluginValidateCommand(argv);
|
|
43
|
+
break;
|
|
44
|
+
case "mcp-stdio":
|
|
45
|
+
await mcpStdioCommand(argv);
|
|
46
|
+
break;
|
|
47
|
+
case "help":
|
|
48
|
+
case "--help":
|
|
49
|
+
case "-h":
|
|
50
|
+
case undefined:
|
|
51
|
+
printHelp();
|
|
52
|
+
break;
|
|
53
|
+
default:
|
|
54
|
+
throw new Error(`Unknown command: ${command}`);
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(`haechi: ${error.message}`);
|
|
58
|
+
process.exitCode = process.exitCode || 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function initCommand(argv) {
|
|
62
|
+
const options = parseOptions(argv);
|
|
63
|
+
const configPath = options.config ?? DEFAULT_CONFIG_PATH;
|
|
64
|
+
const result = await writeDefaultConfig(configPath, { force: Boolean(options.force) });
|
|
65
|
+
writeJson({
|
|
66
|
+
ok: true,
|
|
67
|
+
command: "init",
|
|
68
|
+
configPath: result.configPath,
|
|
69
|
+
created: result.created,
|
|
70
|
+
keyFile: result.config.keys.keyFile,
|
|
71
|
+
auditPath: result.config.audit.path,
|
|
72
|
+
mode: result.config.mode,
|
|
73
|
+
warnings: [
|
|
74
|
+
"The generated .haechi/dev.keys.json file is for local development only.",
|
|
75
|
+
"Haechi 0.3.x does not include a production KMS/HSM/Vault key provider."
|
|
76
|
+
]
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function protectCommand(argv) {
|
|
81
|
+
const [inputPath, ...rest] = argv;
|
|
82
|
+
if (!inputPath || inputPath.startsWith("--")) {
|
|
83
|
+
throw new Error("protect requires an input JSON file path");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const options = parseOptions(rest);
|
|
87
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
88
|
+
const runtime = createRuntime(config);
|
|
89
|
+
const input = JSON.parse(await readFile(inputPath, "utf8"));
|
|
90
|
+
const effectiveMode = config.policy.mode ?? config.mode;
|
|
91
|
+
const result = await runtime.haechi.protectJson(input, {
|
|
92
|
+
protocol: config.target.type,
|
|
93
|
+
operation: "cli protect",
|
|
94
|
+
mode: effectiveMode
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const enforced = !["dry-run", "report-only"].includes(effectiveMode);
|
|
98
|
+
writeJson({
|
|
99
|
+
ok: !result.blocked,
|
|
100
|
+
mode: effectiveMode,
|
|
101
|
+
enforced,
|
|
102
|
+
blocked: result.blocked,
|
|
103
|
+
auditId: result.auditEvent.id,
|
|
104
|
+
summary: result.summary,
|
|
105
|
+
payload: result.payload,
|
|
106
|
+
warnings: enforced ? [] : [
|
|
107
|
+
`policy mode is ${effectiveMode}: detections were audited but the payload was NOT modified or blocked. Set policy.mode to "enforce" to protect payloads.`
|
|
108
|
+
]
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (result.blocked) {
|
|
112
|
+
process.exitCode = 3;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function reportCommand(argv) {
|
|
117
|
+
const options = parseOptions(argv);
|
|
118
|
+
const auditPath = options.audit ?? options.path ?? ".haechi/audit.jsonl";
|
|
119
|
+
writeJson({
|
|
120
|
+
ok: true,
|
|
121
|
+
auditPath,
|
|
122
|
+
summary: await readAuditSummary(auditPath)
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function proxyCommand(argv) {
|
|
127
|
+
const options = parseOptions(argv);
|
|
128
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
129
|
+
const runtime = createRuntime(config);
|
|
130
|
+
const port = parsePort(options.port ?? config.proxy.port);
|
|
131
|
+
const host = options.host ?? config.proxy.host;
|
|
132
|
+
const allowRemoteBind = Boolean(options["allow-remote-bind"]);
|
|
133
|
+
const proxy = createHaechiProxy({ runtime, port, host, allowRemoteBind });
|
|
134
|
+
const address = await proxy.listen();
|
|
135
|
+
|
|
136
|
+
const effectiveMode = config.policy.mode ?? config.mode;
|
|
137
|
+
console.log(`Haechi proxy listening on http://${address.host}:${address.port}`);
|
|
138
|
+
console.log(`Upstream: ${config.target.upstream}`);
|
|
139
|
+
console.log(`Mode: ${effectiveMode}`);
|
|
140
|
+
if (allowRemoteBind) {
|
|
141
|
+
console.error("warning: --allow-remote-bind exposes the proxy beyond loopback. Put Haechi behind explicit network access controls.");
|
|
142
|
+
}
|
|
143
|
+
if (effectiveMode !== "enforce") {
|
|
144
|
+
console.error(`warning: policy mode is ${effectiveMode}. Payloads are inspected and audited but NOT modified or blocked. Set policy.mode to "enforce" to protect traffic.`);
|
|
145
|
+
}
|
|
146
|
+
if (!config.responseProtection.enabled) {
|
|
147
|
+
console.error("warning: responseProtection.enabled is false. Upstream responses are forwarded without inspection.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
151
|
+
process.once(signal, async () => {
|
|
152
|
+
await proxy.close();
|
|
153
|
+
process.exit(0);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function policySignCommand(argv) {
|
|
159
|
+
const [policyPath, ...rest] = argv;
|
|
160
|
+
if (!policyPath || policyPath.startsWith("--")) {
|
|
161
|
+
throw new Error("policy-sign requires a policy JSON file path");
|
|
162
|
+
}
|
|
163
|
+
const options = parseOptions(rest);
|
|
164
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
165
|
+
const outPath = options.out ?? "policy.bundle.json";
|
|
166
|
+
const bundle = await signPolicyBundleFile({
|
|
167
|
+
policyPath,
|
|
168
|
+
keyFile: config.keys.keyFile,
|
|
169
|
+
outPath
|
|
170
|
+
});
|
|
171
|
+
writeJson({
|
|
172
|
+
ok: true,
|
|
173
|
+
command: "policy-sign",
|
|
174
|
+
outPath,
|
|
175
|
+
kid: bundle.kid,
|
|
176
|
+
signedAt: bundle.signedAt
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function policyVerifyCommand(argv) {
|
|
181
|
+
const [bundlePath, ...rest] = argv;
|
|
182
|
+
if (!bundlePath || bundlePath.startsWith("--")) {
|
|
183
|
+
throw new Error("policy-verify requires a policy bundle JSON file path");
|
|
184
|
+
}
|
|
185
|
+
const options = parseOptions(rest);
|
|
186
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
187
|
+
writeJson({
|
|
188
|
+
ok: true,
|
|
189
|
+
command: "policy-verify",
|
|
190
|
+
bundlePath,
|
|
191
|
+
result: await verifyPolicyBundleFile({
|
|
192
|
+
bundlePath,
|
|
193
|
+
keyFile: config.keys.keyFile
|
|
194
|
+
})
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function tokenRevealCommand(argv) {
|
|
199
|
+
const [token, ...rest] = argv;
|
|
200
|
+
if (!token || token.startsWith("--")) {
|
|
201
|
+
throw new Error("token-reveal requires a token");
|
|
202
|
+
}
|
|
203
|
+
const options = parseOptions(rest);
|
|
204
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
205
|
+
if (options["allow-dev-reveal"]) {
|
|
206
|
+
config.tokenVault.revealPolicy = "local-dev";
|
|
207
|
+
}
|
|
208
|
+
const runtime = createRuntime(config);
|
|
209
|
+
const result = await runtime.tokenVault.reveal({ token });
|
|
210
|
+
writeJson({
|
|
211
|
+
ok: true,
|
|
212
|
+
token: result.token,
|
|
213
|
+
type: result.type,
|
|
214
|
+
plaintext: result.plaintext
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function tokenPurgeCommand(argv) {
|
|
219
|
+
const options = parseOptions(argv);
|
|
220
|
+
|
|
221
|
+
if (options.expired) {
|
|
222
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
223
|
+
const runtime = createRuntime(config);
|
|
224
|
+
if (typeof runtime.tokenVault.purgeExpired !== "function") {
|
|
225
|
+
throw new Error("Configured token vault provider does not support purgeExpired");
|
|
226
|
+
}
|
|
227
|
+
writeJson({
|
|
228
|
+
ok: true,
|
|
229
|
+
command: "token-purge",
|
|
230
|
+
result: await runtime.tokenVault.purgeExpired()
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const [token] = argv;
|
|
236
|
+
if (!token || token.startsWith("--")) {
|
|
237
|
+
throw new Error("token-purge requires a token or --expired");
|
|
238
|
+
}
|
|
239
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
240
|
+
const runtime = createRuntime(config);
|
|
241
|
+
writeJson({
|
|
242
|
+
ok: true,
|
|
243
|
+
command: "token-purge",
|
|
244
|
+
result: await runtime.tokenVault.purge({ token })
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function tokenExportCommand(argv) {
|
|
249
|
+
const options = parseOptions(argv);
|
|
250
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
251
|
+
const runtime = createRuntime(config);
|
|
252
|
+
writeJson({
|
|
253
|
+
ok: true,
|
|
254
|
+
command: "token-export",
|
|
255
|
+
tokens: await runtime.tokenVault.exportMetadata({
|
|
256
|
+
type: typeof options.type === "string" ? options.type : null
|
|
257
|
+
})
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function pluginValidateCommand(argv) {
|
|
262
|
+
const [manifestPath] = argv;
|
|
263
|
+
if (!manifestPath || manifestPath.startsWith("--")) {
|
|
264
|
+
throw new Error("plugin-validate requires a plugin manifest JSON file path");
|
|
265
|
+
}
|
|
266
|
+
const result = await validatePluginManifestFile(manifestPath);
|
|
267
|
+
writeJson({
|
|
268
|
+
ok: result.valid,
|
|
269
|
+
command: "plugin-validate",
|
|
270
|
+
manifestPath,
|
|
271
|
+
result
|
|
272
|
+
});
|
|
273
|
+
if (!result.valid) {
|
|
274
|
+
process.exitCode = 2;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function mcpStdioCommand(argv) {
|
|
279
|
+
const options = parseOptions(argv);
|
|
280
|
+
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
281
|
+
const runtime = createRuntime(config);
|
|
282
|
+
await runMcpStdioFilter({ runtime });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseOptions(argv) {
|
|
286
|
+
const options = {};
|
|
287
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
288
|
+
const arg = argv[index];
|
|
289
|
+
if (!arg.startsWith("--")) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const key = arg.slice(2);
|
|
293
|
+
const next = argv[index + 1];
|
|
294
|
+
if (!next || next.startsWith("--")) {
|
|
295
|
+
options[key] = true;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
options[key] = next;
|
|
299
|
+
index += 1;
|
|
300
|
+
}
|
|
301
|
+
return options;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function writeJson(value) {
|
|
305
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function parsePort(value) {
|
|
309
|
+
if (typeof value === "boolean") {
|
|
310
|
+
throw new Error("proxy port must be an integer from 0 to 65535");
|
|
311
|
+
}
|
|
312
|
+
if (typeof value === "string" && !/^\d+$/.test(value.trim())) {
|
|
313
|
+
throw new Error("proxy port must be an integer from 0 to 65535");
|
|
314
|
+
}
|
|
315
|
+
const port = typeof value === "number" ? value : Number(value);
|
|
316
|
+
if (!isValidPort(port)) {
|
|
317
|
+
throw new Error("proxy port must be an integer from 0 to 65535");
|
|
318
|
+
}
|
|
319
|
+
return port;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function printHelp() {
|
|
323
|
+
console.log(`Haechi MVP CLI
|
|
324
|
+
|
|
325
|
+
Usage:
|
|
326
|
+
haechi init [--config haechi.config.json] [--force]
|
|
327
|
+
haechi protect <input.json> [--config haechi.config.json]
|
|
328
|
+
haechi report [--audit .haechi/audit.jsonl]
|
|
329
|
+
haechi proxy [--config haechi.config.json] [--host 127.0.0.1] [--port ${DEFAULT_PROXY_PORT}] [--allow-remote-bind]
|
|
330
|
+
haechi policy-sign <policy.json> [--config haechi.config.json] [--out policy.bundle.json]
|
|
331
|
+
haechi policy-verify <policy.bundle.json> [--config haechi.config.json]
|
|
332
|
+
haechi token-reveal <token> [--config haechi.config.json] [--allow-dev-reveal]
|
|
333
|
+
haechi token-purge <token> [--config haechi.config.json]
|
|
334
|
+
haechi token-purge --expired [--config haechi.config.json]
|
|
335
|
+
haechi token-export [--config haechi.config.json] [--type email]
|
|
336
|
+
haechi plugin-validate <plugin-manifest.json>
|
|
337
|
+
haechi mcp-stdio [--config haechi.config.json]
|
|
338
|
+
|
|
339
|
+
The default policy mode is dry-run. Change policy.mode to enforce to mutate or block payloads.
|
|
340
|
+
`);
|
|
341
|
+
}
|