@gethelio/proxy 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/dist/cli.js CHANGED
@@ -5303,13 +5303,22 @@ var installScanBody = z4.object({
5303
5303
  }),
5304
5304
  metadata: metadataSchema
5305
5305
  });
5306
+ var evidenceEntrySchema = z4.object({
5307
+ evidence_key: z4.string().min(1),
5308
+ evidence_data: z4.unknown().refine((v) => v !== void 0, { message: "Required" }),
5309
+ ttl_seconds: z4.number().int().positive().optional()
5310
+ });
5306
5311
  var auditBody = z4.object({
5307
5312
  evaluation_id: z4.string().min(1),
5308
5313
  status: z4.enum(["success", "error", "not_executed"]),
5309
5314
  error: z4.string().optional(),
5310
5315
  duration_ms: z4.number().optional(),
5311
5316
  result: z4.unknown().optional(),
5312
- actual_amount: z4.number().optional()
5317
+ actual_amount: z4.number().optional(),
5318
+ // No `.max()` / size refinement here on purpose (issue #11): caps are
5319
+ // enforced per-entry in GovernanceService.populateEvidence as soft-drops, so
5320
+ // an over-cap entry never 400s away the audit row for a call that already ran.
5321
+ evidence: z4.array(evidenceEntrySchema).optional()
5313
5322
  });
5314
5323
  var resolveBody = z4.object({
5315
5324
  resolution: z4.enum(["approved", "denied", "timeout", "cancelled"]),
@@ -5411,10 +5420,19 @@ function auditPayloadHash(data) {
5411
5420
  error: data.error ?? null,
5412
5421
  duration_ms: data.duration_ms ?? null,
5413
5422
  result: data.result ?? null,
5414
- actual_amount: data.actual_amount ?? null
5423
+ actual_amount: data.actual_amount ?? null,
5424
+ evidence: canonicalEvidence(data.evidence)
5415
5425
  };
5416
5426
  return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
5417
5427
  }
5428
+ function canonicalEvidence(evidence) {
5429
+ if (!evidence || evidence.length === 0) return null;
5430
+ return evidence.map((e) => ({
5431
+ evidence_key: e.evidence_key,
5432
+ evidence_data: e.evidence_data ?? null,
5433
+ ttl_seconds: e.ttl_seconds ?? null
5434
+ })).map((norm) => ({ sortKey: canonicalize(norm), norm })).sort((a, b) => a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0).map((x) => x.norm);
5435
+ }
5418
5436
  function asStatus(status) {
5419
5437
  return status;
5420
5438
  }
@@ -5547,6 +5565,8 @@ var MAX_TOOL_INPUT_BYTES = 64 * 1024;
5547
5565
  var MAX_PENDING_COUNT = 1e4;
5548
5566
  var MAX_PENDING_BYTES = 64 * 1024 * 1024;
5549
5567
  var MAX_SENDER_KEYS = 5e4;
5568
+ var MAX_EVIDENCE_ENTRIES = 16;
5569
+ var MAX_EVIDENCE_BYTES = 64 * 1024;
5550
5570
  var SWEEP_INTERVAL_MS2 = 3e4;
5551
5571
  var GovernanceService = class {
5552
5572
  policy;
@@ -5837,6 +5857,7 @@ var GovernanceService = class {
5837
5857
  if (callHappened && this.evidenceStore && entry.sessionId) {
5838
5858
  this.evidenceStore.recordToolCall(entry.sessionId, entry.toolName, req.status === "success");
5839
5859
  }
5860
+ const evidenceOutcomes = this.populateEvidence(req, entry);
5840
5861
  const auditId = this.writeAudit({
5841
5862
  timestampIso: entry.timestampIso,
5842
5863
  origin: entry.origin,
@@ -5866,7 +5887,62 @@ var GovernanceService = class {
5866
5887
  finalizedBy: "audit",
5867
5888
  expiresAtMs: this.now() + this.ttlMs
5868
5889
  });
5869
- return { status: 201, body: { ok: true, audit_record_id: auditId } };
5890
+ const body = { ok: true, audit_record_id: auditId };
5891
+ if (evidenceOutcomes) body["evidence"] = evidenceOutcomes;
5892
+ return { status: 201, body };
5893
+ }
5894
+ /**
5895
+ * Write the optional `/audit` evidence entries for a successful call
5896
+ * (issue #11), returning a per-entry outcome list — or `undefined`
5897
+ * when there is nothing to report (non-success status, or no evidence
5898
+ * supplied). Caps are enforced here, NOT in route validation, so an over-cap
5899
+ * entry soft-drops without discarding the audit row: entries past
5900
+ * `MAX_EVIDENCE_ENTRIES` → `too_many`; oversized `evidence_data` →
5901
+ * `too_large`; no evidence store on the service → `evidence_unavailable`;
5902
+ * a sessionless evaluation → `no_session`; the store's own rejections
5903
+ * (`key_not_in_policy_allowlist`, `closed`) pass through as the per-entry
5904
+ * reason. None of these fail the audit.
5905
+ */
5906
+ populateEvidence(req, entry) {
5907
+ if (req.status !== "success" || !req.evidence || req.evidence.length === 0) {
5908
+ return void 0;
5909
+ }
5910
+ const outcomes = [];
5911
+ for (let i = 0; i < req.evidence.length; i++) {
5912
+ const e = req.evidence[i];
5913
+ if (!e) continue;
5914
+ if (i >= MAX_EVIDENCE_ENTRIES) {
5915
+ outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "too_many" });
5916
+ continue;
5917
+ }
5918
+ const bytes = Buffer.byteLength(canonicalize(e.evidence_data ?? null), "utf8");
5919
+ if (bytes > MAX_EVIDENCE_BYTES) {
5920
+ outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "too_large" });
5921
+ continue;
5922
+ }
5923
+ if (!this.evidenceStore) {
5924
+ outcomes.push({
5925
+ evidence_key: e.evidence_key,
5926
+ stored: false,
5927
+ reason: "evidence_unavailable"
5928
+ });
5929
+ continue;
5930
+ }
5931
+ if (!entry.sessionId) {
5932
+ outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "no_session" });
5933
+ continue;
5934
+ }
5935
+ const result = this.evidenceStore.putEvidence(entry.sessionId, {
5936
+ evidence_key: e.evidence_key,
5937
+ data: e.evidence_data,
5938
+ tool_name: entry.toolName,
5939
+ ttl_seconds: e.ttl_seconds
5940
+ });
5941
+ outcomes.push(
5942
+ result.stored ? { evidence_key: e.evidence_key, stored: true } : { evidence_key: e.evidence_key, stored: false, reason: result.reason }
5943
+ );
5944
+ }
5945
+ return outcomes;
5870
5946
  }
5871
5947
  // -------------------------------------------------------------------------
5872
5948
  // POST /install-scan — evaluates install-time policy (issue #13)
@@ -7969,6 +8045,13 @@ function warnIfDashboardOpenMode(config, log = console.error) {
7969
8045
  );
7970
8046
  return true;
7971
8047
  }
8048
+ function warnIfNoEnforcement(policy, log = console.error) {
8049
+ if (policy.rules.length > 0 || policy.defaultAction !== "allow" || policy.dryRun) return false;
8050
+ log(
8051
+ '[helio] No policy rules are loaded and the default action is "allow" - Helio is recording an audit trail but NOT blocking anything. Add rules under `policies:` in helio.yaml to start enforcing (see docs/policies.md).'
8052
+ );
8053
+ return true;
8054
+ }
7972
8055
 
7973
8056
  // src/crash-drain.ts
7974
8057
  var hooks = [];
@@ -8041,18 +8124,6 @@ upstream:
8041
8124
  # headers:
8042
8125
  # Authorization: "Bearer \${UPSTREAM_TOKEN}"
8043
8126
 
8044
- # Operator dashboard + approval REST API. Bound to 127.0.0.1 by default \u2014 do
8045
- # not change to 0.0.0.0 without putting an authenticating reverse proxy in
8046
- # front. dashboard.api_secret is the manual dashboard login secret and also
8047
- # supports machine Bearer auth for sideband API clients. Store it safely; it
8048
- # stays valid until you rotate it. Rotate by editing this file and restarting
8049
- # (or hot-reloading) the proxy. Rotation invalidates active dashboard sessions.
8050
- dashboard:
8051
- enabled: true
8052
- port: 3100
8053
- host: 127.0.0.1
8054
- api_secret: "${apiSecret}"
8055
-
8056
8127
  # listen:
8057
8128
  # port: 3000
8058
8129
  # host: 127.0.0.1
@@ -8073,6 +8144,18 @@ dashboard:
8073
8144
  # retention: 90d
8074
8145
  # include_responses: true
8075
8146
 
8147
+ # Operator dashboard + approval REST API. Bound to 127.0.0.1 by default \u2014 do
8148
+ # not change to 0.0.0.0 without putting an authenticating reverse proxy in
8149
+ # front. dashboard.api_secret is the manual dashboard login secret and also
8150
+ # supports machine Bearer auth for sideband API clients. Store it safely; it
8151
+ # stays valid until you rotate it. Rotate by editing this file and restarting
8152
+ # (or hot-reloading) the proxy. Rotation invalidates active dashboard sessions.
8153
+ dashboard:
8154
+ enabled: true
8155
+ port: 3100
8156
+ host: 127.0.0.1
8157
+ api_secret: "${apiSecret}"
8158
+
8076
8159
  # sdk:
8077
8160
  # enabled: false
8078
8161
  # port: 3200
@@ -8368,6 +8451,7 @@ async function startCommand(configPath, options) {
8368
8451
  console.error(
8369
8452
  `Policies: ${String(ruleCount)} rule${ruleCount !== 1 ? "s" : ""} loaded (default: ${policy.defaultAction})`
8370
8453
  );
8454
+ warnIfNoEnforcement(policy);
8371
8455
  if (config.upstream.transport === "stdio") {
8372
8456
  console.error(`Upstream: ${config.upstream.command ?? ""} (stdio)`);
8373
8457
  } else {
package/dist/index.d.ts CHANGED
@@ -1932,6 +1932,11 @@ interface InstallScanInput {
1932
1932
  };
1933
1933
  readonly metadata: Record<string, unknown> | null;
1934
1934
  }
1935
+ interface AuditEvidenceInput {
1936
+ readonly evidence_key: string;
1937
+ readonly evidence_data: unknown;
1938
+ readonly ttl_seconds?: number;
1939
+ }
1935
1940
  interface AuditInput {
1936
1941
  readonly evaluation_id: string;
1937
1942
  readonly status: 'success' | 'error' | 'not_executed';
@@ -1939,6 +1944,12 @@ interface AuditInput {
1939
1944
  readonly duration_ms?: number;
1940
1945
  readonly result?: unknown;
1941
1946
  readonly actual_amount?: number;
1947
+ /**
1948
+ * Optional evidence to populate on a successfully-audited call (issue #11). Adapter-scoped, single-token evidence write: bound to the pending
1949
+ * evaluation's session/tool, success-only, first-finalize-only. Every
1950
+ * per-entry failure is soft (reported, never request-fatal) — see audit().
1951
+ */
1952
+ readonly evidence?: ReadonlyArray<AuditEvidenceInput>;
1942
1953
  }
1943
1954
  interface ResolveApprovalInput {
1944
1955
  readonly resolution: 'approved' | 'denied' | 'timeout' | 'cancelled';
@@ -2004,6 +2015,19 @@ declare class GovernanceService {
2004
2015
  updatePolicy(policy: CompiledPolicy): void;
2005
2016
  evaluate(req: EvaluateInput): ServiceResult;
2006
2017
  audit(req: AuditInput, payloadHash: string): ServiceResult;
2018
+ /**
2019
+ * Write the optional `/audit` evidence entries for a successful call
2020
+ * (issue #11), returning a per-entry outcome list — or `undefined`
2021
+ * when there is nothing to report (non-success status, or no evidence
2022
+ * supplied). Caps are enforced here, NOT in route validation, so an over-cap
2023
+ * entry soft-drops without discarding the audit row: entries past
2024
+ * `MAX_EVIDENCE_ENTRIES` → `too_many`; oversized `evidence_data` →
2025
+ * `too_large`; no evidence store on the service → `evidence_unavailable`;
2026
+ * a sessionless evaluation → `no_session`; the store's own rejections
2027
+ * (`key_not_in_policy_allowlist`, `closed`) pass through as the per-entry
2028
+ * reason. None of these fail the audit.
2029
+ */
2030
+ private populateEvidence;
2007
2031
  installScan(req: InstallScanInput): ServiceResult;
2008
2032
  /** First-match-wins evaluation of the compiled install policy (issue #13). */
2009
2033
  private evaluateInstall;
package/dist/index.js CHANGED
@@ -4584,13 +4584,22 @@ var installScanBody = z4.object({
4584
4584
  }),
4585
4585
  metadata: metadataSchema
4586
4586
  });
4587
+ var evidenceEntrySchema = z4.object({
4588
+ evidence_key: z4.string().min(1),
4589
+ evidence_data: z4.unknown().refine((v) => v !== void 0, { message: "Required" }),
4590
+ ttl_seconds: z4.number().int().positive().optional()
4591
+ });
4587
4592
  var auditBody = z4.object({
4588
4593
  evaluation_id: z4.string().min(1),
4589
4594
  status: z4.enum(["success", "error", "not_executed"]),
4590
4595
  error: z4.string().optional(),
4591
4596
  duration_ms: z4.number().optional(),
4592
4597
  result: z4.unknown().optional(),
4593
- actual_amount: z4.number().optional()
4598
+ actual_amount: z4.number().optional(),
4599
+ // No `.max()` / size refinement here on purpose (issue #11): caps are
4600
+ // enforced per-entry in GovernanceService.populateEvidence as soft-drops, so
4601
+ // an over-cap entry never 400s away the audit row for a call that already ran.
4602
+ evidence: z4.array(evidenceEntrySchema).optional()
4594
4603
  });
4595
4604
  var resolveBody = z4.object({
4596
4605
  resolution: z4.enum(["approved", "denied", "timeout", "cancelled"]),
@@ -4692,10 +4701,19 @@ function auditPayloadHash(data) {
4692
4701
  error: data.error ?? null,
4693
4702
  duration_ms: data.duration_ms ?? null,
4694
4703
  result: data.result ?? null,
4695
- actual_amount: data.actual_amount ?? null
4704
+ actual_amount: data.actual_amount ?? null,
4705
+ evidence: canonicalEvidence(data.evidence)
4696
4706
  };
4697
4707
  return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
4698
4708
  }
4709
+ function canonicalEvidence(evidence) {
4710
+ if (!evidence || evidence.length === 0) return null;
4711
+ return evidence.map((e) => ({
4712
+ evidence_key: e.evidence_key,
4713
+ evidence_data: e.evidence_data ?? null,
4714
+ ttl_seconds: e.ttl_seconds ?? null
4715
+ })).map((norm) => ({ sortKey: canonicalize(norm), norm })).sort((a, b) => a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0).map((x) => x.norm);
4716
+ }
4699
4717
  function asStatus(status) {
4700
4718
  return status;
4701
4719
  }
@@ -4828,6 +4846,8 @@ var MAX_TOOL_INPUT_BYTES = 64 * 1024;
4828
4846
  var MAX_PENDING_COUNT = 1e4;
4829
4847
  var MAX_PENDING_BYTES = 64 * 1024 * 1024;
4830
4848
  var MAX_SENDER_KEYS = 5e4;
4849
+ var MAX_EVIDENCE_ENTRIES = 16;
4850
+ var MAX_EVIDENCE_BYTES = 64 * 1024;
4831
4851
  var SWEEP_INTERVAL_MS2 = 3e4;
4832
4852
  var GovernanceService = class {
4833
4853
  policy;
@@ -5118,6 +5138,7 @@ var GovernanceService = class {
5118
5138
  if (callHappened && this.evidenceStore && entry.sessionId) {
5119
5139
  this.evidenceStore.recordToolCall(entry.sessionId, entry.toolName, req.status === "success");
5120
5140
  }
5141
+ const evidenceOutcomes = this.populateEvidence(req, entry);
5121
5142
  const auditId = this.writeAudit({
5122
5143
  timestampIso: entry.timestampIso,
5123
5144
  origin: entry.origin,
@@ -5147,7 +5168,62 @@ var GovernanceService = class {
5147
5168
  finalizedBy: "audit",
5148
5169
  expiresAtMs: this.now() + this.ttlMs
5149
5170
  });
5150
- return { status: 201, body: { ok: true, audit_record_id: auditId } };
5171
+ const body = { ok: true, audit_record_id: auditId };
5172
+ if (evidenceOutcomes) body["evidence"] = evidenceOutcomes;
5173
+ return { status: 201, body };
5174
+ }
5175
+ /**
5176
+ * Write the optional `/audit` evidence entries for a successful call
5177
+ * (issue #11), returning a per-entry outcome list — or `undefined`
5178
+ * when there is nothing to report (non-success status, or no evidence
5179
+ * supplied). Caps are enforced here, NOT in route validation, so an over-cap
5180
+ * entry soft-drops without discarding the audit row: entries past
5181
+ * `MAX_EVIDENCE_ENTRIES` → `too_many`; oversized `evidence_data` →
5182
+ * `too_large`; no evidence store on the service → `evidence_unavailable`;
5183
+ * a sessionless evaluation → `no_session`; the store's own rejections
5184
+ * (`key_not_in_policy_allowlist`, `closed`) pass through as the per-entry
5185
+ * reason. None of these fail the audit.
5186
+ */
5187
+ populateEvidence(req, entry) {
5188
+ if (req.status !== "success" || !req.evidence || req.evidence.length === 0) {
5189
+ return void 0;
5190
+ }
5191
+ const outcomes = [];
5192
+ for (let i = 0; i < req.evidence.length; i++) {
5193
+ const e = req.evidence[i];
5194
+ if (!e) continue;
5195
+ if (i >= MAX_EVIDENCE_ENTRIES) {
5196
+ outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "too_many" });
5197
+ continue;
5198
+ }
5199
+ const bytes = Buffer.byteLength(canonicalize(e.evidence_data ?? null), "utf8");
5200
+ if (bytes > MAX_EVIDENCE_BYTES) {
5201
+ outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "too_large" });
5202
+ continue;
5203
+ }
5204
+ if (!this.evidenceStore) {
5205
+ outcomes.push({
5206
+ evidence_key: e.evidence_key,
5207
+ stored: false,
5208
+ reason: "evidence_unavailable"
5209
+ });
5210
+ continue;
5211
+ }
5212
+ if (!entry.sessionId) {
5213
+ outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "no_session" });
5214
+ continue;
5215
+ }
5216
+ const result = this.evidenceStore.putEvidence(entry.sessionId, {
5217
+ evidence_key: e.evidence_key,
5218
+ data: e.evidence_data,
5219
+ tool_name: entry.toolName,
5220
+ ttl_seconds: e.ttl_seconds
5221
+ });
5222
+ outcomes.push(
5223
+ result.stored ? { evidence_key: e.evidence_key, stored: true } : { evidence_key: e.evidence_key, stored: false, reason: result.reason }
5224
+ );
5225
+ }
5226
+ return outcomes;
5151
5227
  }
5152
5228
  // -------------------------------------------------------------------------
5153
5229
  // POST /install-scan — evaluates install-time policy (issue #13)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethelio/proxy",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Open-source MCP governance proxy for AI agents",
6
6
  "license": "Apache-2.0",
@@ -61,7 +61,7 @@
61
61
  "better-sqlite3": "12.8.0",
62
62
  "chokidar": "5.0.0",
63
63
  "commander": "14.0.3",
64
- "hono": "4.12.14",
64
+ "hono": "4.12.26",
65
65
  "js-yaml": "4.1.1",
66
66
  "picomatch": "4.0.4",
67
67
  "safe-regex2": "5.0.0",