@kashscript/hudhud 0.1.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.
Files changed (138) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +67 -0
  3. package/dist/adapters/prisma.d.ts +170 -0
  4. package/dist/adapters/prisma.d.ts.map +1 -0
  5. package/dist/adapters/prisma.js +185 -0
  6. package/dist/adapters/prisma.js.map +1 -0
  7. package/dist/anchor/index.d.ts +55 -0
  8. package/dist/anchor/index.d.ts.map +1 -0
  9. package/dist/anchor/index.js +97 -0
  10. package/dist/anchor/index.js.map +1 -0
  11. package/dist/certificate/certificate.d.ts +115 -0
  12. package/dist/certificate/certificate.d.ts.map +1 -0
  13. package/dist/certificate/certificate.js +221 -0
  14. package/dist/certificate/certificate.js.map +1 -0
  15. package/dist/certificate/html.d.ts +10 -0
  16. package/dist/certificate/html.d.ts.map +1 -0
  17. package/dist/certificate/html.js +141 -0
  18. package/dist/certificate/html.js.map +1 -0
  19. package/dist/certificate/index.d.ts +3 -0
  20. package/dist/certificate/index.d.ts.map +1 -0
  21. package/dist/certificate/index.js +5 -0
  22. package/dist/certificate/index.js.map +1 -0
  23. package/dist/did-registry/index.d.ts +3 -0
  24. package/dist/did-registry/index.d.ts.map +1 -0
  25. package/dist/did-registry/index.js +10 -0
  26. package/dist/did-registry/index.js.map +1 -0
  27. package/dist/errors.d.ts +35 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +65 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/exception/index.d.ts +97 -0
  32. package/dist/exception/index.d.ts.map +1 -0
  33. package/dist/exception/index.js +144 -0
  34. package/dist/exception/index.js.map +1 -0
  35. package/dist/identity/index.d.ts +33 -0
  36. package/dist/identity/index.d.ts.map +1 -0
  37. package/dist/identity/index.js +36 -0
  38. package/dist/identity/index.js.map +1 -0
  39. package/dist/index.d.ts +15 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +32 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/ledger/event-schema.d.ts +97 -0
  44. package/dist/ledger/event-schema.d.ts.map +1 -0
  45. package/dist/ledger/event-schema.js +54 -0
  46. package/dist/ledger/event-schema.js.map +1 -0
  47. package/dist/ledger/index.d.ts +7 -0
  48. package/dist/ledger/index.d.ts.map +1 -0
  49. package/dist/ledger/index.js +14 -0
  50. package/dist/ledger/index.js.map +1 -0
  51. package/dist/ledger/mutex.d.ts +9 -0
  52. package/dist/ledger/mutex.d.ts.map +1 -0
  53. package/dist/ledger/mutex.js +36 -0
  54. package/dist/ledger/mutex.js.map +1 -0
  55. package/dist/ledger/store.d.ts +31 -0
  56. package/dist/ledger/store.d.ts.map +1 -0
  57. package/dist/ledger/store.js +66 -0
  58. package/dist/ledger/store.js.map +1 -0
  59. package/dist/ledger/writer.d.ts +69 -0
  60. package/dist/ledger/writer.d.ts.map +1 -0
  61. package/dist/ledger/writer.js +167 -0
  62. package/dist/ledger/writer.js.map +1 -0
  63. package/dist/mcp/allowlist.d.ts +53 -0
  64. package/dist/mcp/allowlist.d.ts.map +1 -0
  65. package/dist/mcp/allowlist.js +65 -0
  66. package/dist/mcp/allowlist.js.map +1 -0
  67. package/dist/mcp/client.d.ts +68 -0
  68. package/dist/mcp/client.d.ts.map +1 -0
  69. package/dist/mcp/client.js +198 -0
  70. package/dist/mcp/client.js.map +1 -0
  71. package/dist/mcp/index.d.ts +3 -0
  72. package/dist/mcp/index.d.ts.map +1 -0
  73. package/dist/mcp/index.js +5 -0
  74. package/dist/mcp/index.js.map +1 -0
  75. package/dist/privacy/index.d.ts +21 -0
  76. package/dist/privacy/index.d.ts.map +1 -0
  77. package/dist/privacy/index.js +96 -0
  78. package/dist/privacy/index.js.map +1 -0
  79. package/dist/runtime/dispatcher.d.ts +27 -0
  80. package/dist/runtime/dispatcher.d.ts.map +1 -0
  81. package/dist/runtime/dispatcher.js +66 -0
  82. package/dist/runtime/dispatcher.js.map +1 -0
  83. package/dist/runtime/escalation.d.ts +98 -0
  84. package/dist/runtime/escalation.d.ts.map +1 -0
  85. package/dist/runtime/escalation.js +188 -0
  86. package/dist/runtime/escalation.js.map +1 -0
  87. package/dist/runtime/index.d.ts +7 -0
  88. package/dist/runtime/index.d.ts.map +1 -0
  89. package/dist/runtime/index.js +9 -0
  90. package/dist/runtime/index.js.map +1 -0
  91. package/dist/runtime/runner.d.ts +78 -0
  92. package/dist/runtime/runner.d.ts.map +1 -0
  93. package/dist/runtime/runner.js +145 -0
  94. package/dist/runtime/runner.js.map +1 -0
  95. package/dist/scope/index.d.ts +19 -0
  96. package/dist/scope/index.d.ts.map +1 -0
  97. package/dist/scope/index.js +44 -0
  98. package/dist/scope/index.js.map +1 -0
  99. package/dist/sop/compiler.d.ts +36 -0
  100. package/dist/sop/compiler.d.ts.map +1 -0
  101. package/dist/sop/compiler.js +54 -0
  102. package/dist/sop/compiler.js.map +1 -0
  103. package/dist/sop/index.d.ts +3 -0
  104. package/dist/sop/index.d.ts.map +1 -0
  105. package/dist/sop/index.js +5 -0
  106. package/dist/sop/index.js.map +1 -0
  107. package/dist/sop/service.d.ts +40 -0
  108. package/dist/sop/service.d.ts.map +1 -0
  109. package/dist/sop/service.js +140 -0
  110. package/dist/sop/service.js.map +1 -0
  111. package/package.json +112 -0
  112. package/src/adapters/prisma.ts +310 -0
  113. package/src/anchor/index.ts +149 -0
  114. package/src/certificate/certificate.ts +340 -0
  115. package/src/certificate/html.ts +156 -0
  116. package/src/certificate/index.ts +4 -0
  117. package/src/did-registry/index.ts +11 -0
  118. package/src/errors.ts +79 -0
  119. package/src/exception/index.ts +244 -0
  120. package/src/identity/index.ts +82 -0
  121. package/src/index.ts +33 -0
  122. package/src/ledger/event-schema.ts +61 -0
  123. package/src/ledger/index.ts +22 -0
  124. package/src/ledger/mutex.ts +37 -0
  125. package/src/ledger/store.ts +86 -0
  126. package/src/ledger/writer.ts +249 -0
  127. package/src/mcp/allowlist.ts +102 -0
  128. package/src/mcp/client.ts +284 -0
  129. package/src/mcp/index.ts +4 -0
  130. package/src/privacy/index.ts +123 -0
  131. package/src/runtime/dispatcher.ts +96 -0
  132. package/src/runtime/escalation.ts +292 -0
  133. package/src/runtime/index.ts +14 -0
  134. package/src/runtime/runner.ts +227 -0
  135. package/src/scope/index.ts +59 -0
  136. package/src/sop/compiler.ts +99 -0
  137. package/src/sop/index.ts +4 -0
  138. package/src/sop/service.ts +184 -0
@@ -0,0 +1,123 @@
1
+ // ============================================================================
2
+ // @kashscript/hudhud/privacy — PII + secret redaction interceptor
3
+ // ----------------------------------------------------------------------------
4
+ // Scans tool arguments (and any JSON tree) for embedded secrets (API keys,
5
+ // JWTs, bearer tokens) and PII (SSN, PAN, CNIC, …). Returns a redacted copy +
6
+ // a violation list. RECALL-biased: over-redact rather than leak; the redaction
7
+ // marker `[REDACTED:<pattern-id>]` names the detector without exposing the raw
8
+ // value. Pure — never mutates the input. Ported verbatim from the
9
+ // oreoasis-core-host engine (M5, ADR-0016).
10
+ // ============================================================================
11
+
12
+ export interface PrivacyPattern {
13
+ readonly id: string;
14
+ readonly description: string;
15
+ readonly regex: RegExp;
16
+ }
17
+
18
+ /** Built-in catalogue. Append-only — callers extend via `scanAndRedact`'s 2nd arg. */
19
+ export const BUILTIN_PRIVACY_PATTERNS: ReadonlyArray<PrivacyPattern> = [
20
+ // ── secret formats ─────────────────────────────────────────────
21
+ { id: "openai-key", description: "OpenAI / Anthropic-style API key", regex: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
22
+ { id: "anthropic-key", description: "Anthropic API key (explicit prefix)", regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
23
+ { id: "github-pat", description: "GitHub personal access token", regex: /\bghp_[A-Za-z0-9]{36}\b/ },
24
+ { id: "github-app-token", description: "GitHub app installation token", regex: /\bgh[sou]_[A-Za-z0-9]{36}\b/ },
25
+ { id: "aws-access-key-id", description: "AWS access key id", regex: /\b(?:AKIA|ASIA|AROA|AIDA)[A-Z0-9]{16}\b/ },
26
+ { id: "google-api-key", description: "Google Cloud API key", regex: /\bAIza[0-9A-Za-z_-]{35}\b/ },
27
+ { id: "jwt-token", description: "JWT-shaped string (three base64url segments)", regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
28
+ { id: "bearer-token", description: "Authorization: Bearer header value", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/i },
29
+ { id: "slack-token", description: "Slack workspace / bot token", regex: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/ },
30
+ { id: "private-key-pem", description: "Embedded PEM private key header", regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |)PRIVATE KEY-----/ },
31
+ // ── PII formats ────────────────────────────────────────────────
32
+ { id: "us-ssn", description: "US Social Security Number (NNN-NN-NNNN)", regex: /\b\d{3}-\d{2}-\d{4}\b/ },
33
+ { id: "credit-card-16", description: "16-digit grouped credit card number", regex: /\b(?:\d{4}[- ]){3}\d{4}\b/ },
34
+ { id: "in-pan", description: "Indian PAN card number", regex: /\b[A-Z]{5}\d{4}[A-Z]\b/ },
35
+ { id: "in-aadhaar-grouped", description: "Indian Aadhaar number (12 digits, optional grouping)", regex: /\b\d{4}[- ]?\d{4}[- ]?\d{4}\b/ },
36
+ { id: "pk-cnic", description: "Pakistan CNIC (13 digits, 5-7-1 grouping)", regex: /\b\d{5}-\d{7}-\d\b/ },
37
+ { id: "pk-mobile", description: "Pakistan mobile number (03XX-XXXXXXX)", regex: /\b03\d{2}-?\d{7}\b/ },
38
+ ];
39
+
40
+ export interface PrivacyViolation {
41
+ readonly path: string;
42
+ readonly patternId: string;
43
+ readonly patternDescription: string;
44
+ }
45
+
46
+ export interface PrivacyScanResult {
47
+ readonly ok: boolean;
48
+ readonly redacted: Readonly<Record<string, unknown>>;
49
+ readonly violations: ReadonlyArray<PrivacyViolation>;
50
+ readonly redactionCount: number;
51
+ }
52
+
53
+ /** Scan the input tree; return a redacted copy + the violation list. Pure. */
54
+ export function scanAndRedact(
55
+ input: Record<string, unknown>,
56
+ extraPatterns: ReadonlyArray<PrivacyPattern> = [],
57
+ ): PrivacyScanResult {
58
+ const patterns = [...BUILTIN_PRIVACY_PATTERNS, ...extraPatterns];
59
+ const violations: PrivacyViolation[] = [];
60
+ const redacted = scanNode(input, "$", patterns, violations) as Record<string, unknown>;
61
+ return {
62
+ ok: violations.length === 0,
63
+ redacted: Object.freeze(redacted),
64
+ violations: Object.freeze(violations),
65
+ redactionCount: violations.length,
66
+ };
67
+ }
68
+
69
+ function firstMatch(s: string, patterns: ReadonlyArray<PrivacyPattern>): PrivacyPattern | null {
70
+ for (const pattern of patterns) {
71
+ if (pattern.regex.test(s)) return pattern;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function scanNode(
77
+ node: unknown,
78
+ path: string,
79
+ patterns: ReadonlyArray<PrivacyPattern>,
80
+ violations: PrivacyViolation[],
81
+ ): unknown {
82
+ if (typeof node === "string") {
83
+ const m = firstMatch(node, patterns);
84
+ if (m) {
85
+ violations.push({ path, patternId: m.id, patternDescription: m.description });
86
+ return `[REDACTED:${m.id}]`;
87
+ }
88
+ return node;
89
+ }
90
+ // audit r1 F4: a numeric/bigint LEAF can itself be a secret (e.g. a PAN or a
91
+ // 12-digit Aadhaar carried as a JS number) — test its string form, don't skip it.
92
+ if (typeof node === "number" || typeof node === "bigint") {
93
+ const m = firstMatch(String(node), patterns);
94
+ if (m) {
95
+ violations.push({ path, patternId: m.id, patternDescription: m.description });
96
+ return `[REDACTED:${m.id}]`;
97
+ }
98
+ return node;
99
+ }
100
+ if (Array.isArray(node)) {
101
+ return node.map((el, i) => scanNode(el, `${path}[${i}]`, patterns, violations));
102
+ }
103
+ if (node !== null && typeof node === "object") {
104
+ const out: Record<string, unknown> = {};
105
+ for (const [k, v] of Object.entries(node)) {
106
+ const scannedValue = scanNode(v, `${path}.${k}`, patterns, violations);
107
+ // audit r1 F4: a secret can hide in a KEY position, not just a value — a
108
+ // matching key is redacted so it never survives into the signed ledger.
109
+ const km = firstMatch(k, patterns);
110
+ if (km) {
111
+ violations.push({ path: `${path}.${k}`, patternId: km.id, patternDescription: `${km.description} (object key)` });
112
+ let redKey = `[REDACTED-KEY:${km.id}]`;
113
+ let n = 2;
114
+ while (redKey in out) redKey = `[REDACTED-KEY:${km.id}#${n++}]`;
115
+ out[redKey] = scannedValue;
116
+ } else {
117
+ out[k] = scannedValue;
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ return node;
123
+ }
@@ -0,0 +1,96 @@
1
+ // ============================================================================
2
+ // @kashscript/hudhud/runtime — NeuronToolDispatcher
3
+ // ----------------------------------------------------------------------------
4
+ // The seam where neuron's DAGExecutor reaches the host's real MCP tool
5
+ // transport, with the accountability append done INLINE on the awaited dispatch
6
+ // path. Neuron's `onEvent` is synchronous fire-and-forget (no back-pressure) —
7
+ // so the signed ledger row is appended HERE, inside the awaited `dispatch`, so
8
+ // a failed append fails the node and the run can never observe an unrecorded
9
+ // tool result. Ported from the oreoasis-core-host engine (M5); telemetry dropped.
10
+ // ============================================================================
11
+
12
+ import { ToolDispatchError } from "@kashscript/neuron/kernel";
13
+ import type { DispatchInput, DispatchResult, ToolDispatcher } from "@kashscript/neuron/kernel";
14
+ import type { ToolManifest } from "@kashscript/neuron/schemas";
15
+
16
+ import type { McpClientPoolManager } from "../mcp";
17
+ import type { ForensicLedgerService } from "../ledger/writer";
18
+
19
+ /** Binds one tool (by manifest) to the MCP endpoint that executes it. */
20
+ export interface NeuronToolBinding {
21
+ readonly manifest: ToolManifest;
22
+ readonly mcpEndpointUrl: string;
23
+ readonly mcpToolName?: string;
24
+ }
25
+
26
+ export interface NeuronToolDispatcherDeps {
27
+ readonly mcpClientPool: McpClientPoolManager;
28
+ readonly forensicLedger: ForensicLedgerService;
29
+ /** Session whose ledger chain every dispatch in this run appends to. */
30
+ readonly sessionId: string;
31
+ readonly bindings: ReadonlyMap<string, NeuronToolBinding>;
32
+ }
33
+
34
+ export class NeuronToolDispatcher implements ToolDispatcher {
35
+ constructor(private readonly deps: NeuronToolDispatcherDeps) {}
36
+
37
+ /** Resolve a tool name to its manifest, or null when unbound. */
38
+ resolve(toolName: string): ToolManifest | null {
39
+ return this.deps.bindings.get(toolName)?.manifest ?? null;
40
+ }
41
+
42
+ /** Execute one tool call: resolve binding → real transport → (on JSON-RPC
43
+ * error) EXECUTOR_FAILED (no row) → INLINE signed ledger append → return. */
44
+ async dispatch(input: DispatchInput): Promise<DispatchResult> {
45
+ const toolName = input.tool.name;
46
+ const binding = this.deps.bindings.get(toolName);
47
+ if (binding === undefined) {
48
+ throw new ToolDispatchError(
49
+ `NeuronToolDispatcher: no binding registered for tool '${toolName}' (dagNodeId=${input.dagNodeId})`,
50
+ "TOOL_NOT_FOUND",
51
+ );
52
+ }
53
+
54
+ const mcpToolName = binding.mcpToolName ?? toolName;
55
+ const transport = await this.deps.mcpClientPool.executeMcpToolCall(binding.mcpEndpointUrl, mcpToolName, input.parameters);
56
+
57
+ if (!transport.ok) {
58
+ // audit r1 F8: a money-moving tool can fail AFTER a partial side-effect, so
59
+ // record the FAILED dispatch on the signed ledger BEFORE throwing — every
60
+ // dispatch (success or application-error) leaves an accountable row.
61
+ await this.deps.forensicLedger.appendActorTransition({
62
+ sessionId: this.deps.sessionId,
63
+ payloadMutation: {
64
+ event: "tool.failed",
65
+ tool: toolName,
66
+ dagNodeId: input.dagNodeId,
67
+ jsonRpcId: transport.jsonRpcId,
68
+ errorCode: String(transport.error.code),
69
+ errorMessage: transport.error.message,
70
+ },
71
+ });
72
+ throw new ToolDispatchError(
73
+ `NeuronToolDispatcher: MCP tool '${mcpToolName}' returned a JSON-RPC error (code=${transport.error.code}): ${transport.error.message}`,
74
+ "EXECUTOR_FAILED",
75
+ );
76
+ }
77
+
78
+ // INLINE accountability append — BEFORE returning. A throw here fails the
79
+ // node (the executor awaits us), so no unrecorded result can be observed.
80
+ await this.deps.forensicLedger.appendActorTransition({
81
+ sessionId: this.deps.sessionId,
82
+ payloadMutation: {
83
+ event: "tool.dispatch",
84
+ tool: toolName,
85
+ dagNodeId: input.dagNodeId,
86
+ jsonRpcId: transport.jsonRpcId,
87
+ result: transport.result,
88
+ },
89
+ });
90
+
91
+ return {
92
+ output: transport.result,
93
+ cost: { usdMicros: input.tool.costHint.usdMicros, latencyMs: transport.latencyMs },
94
+ };
95
+ }
96
+ }
@@ -0,0 +1,292 @@
1
+ // ============================================================================
2
+ // @kashscript/hudhud/runtime — escalation: two-key suspend/resume
3
+ // ----------------------------------------------------------------------------
4
+ // When an autonomous run reaches a RESTRICTED tool it SUSPENDS; a human must
5
+ // sign off before it may RESUME. Two artifacts make "the host cannot forge
6
+ // human judgment" literally true:
7
+ // 1. buildHumanApprovalEnvelope — the TWO-KEY envelope: the AGENT key (which
8
+ // the host holds) attests the proposed action; a SEPARATE OPERATOR key
9
+ // signs the challenge. The host cannot mint the invoker signature.
10
+ // 2. appendOperatorSignoff — a SIGNED ledger row on the OPERATOR key
11
+ // (actorClass OPERATOR_ADMIN) + the mandatory exception classification;
12
+ // forensicConsistencyScan re-verifies it.
13
+ // 3. ResumeGuard — a one-way claim() so a retried resume can't re-fire a tool.
14
+ // Ported from the oreoasis-core-host engine (M5); telemetry dropped, ledger +
15
+ // exception + neuron/handshake reused.
16
+ // ============================================================================
17
+
18
+ import { deriveDid, signEvent, verifyEvent } from "@kashscript/identity-core";
19
+ import { KASH_DID_PATTERN, signLedgerBlock } from "@kashscript/attest";
20
+ import {
21
+ PermissionGate,
22
+ assembleEnvelope,
23
+ stripDerivedFields,
24
+ } from "@kashscript/neuron/handshake";
25
+ import type { DID, KashEventSignature, Sha256Hex, SignedActionEnvelope, ToolCall } from "@kashscript/neuron/handshake";
26
+ import { NEURON_DST } from "@kashscript/neuron/schemas";
27
+
28
+ import {
29
+ assertFailureClass,
30
+ buildSignedClassification,
31
+ } from "../exception";
32
+ import type { ExceptionClassificationInput, ExceptionClassificationRecord, ExceptionClassificationStore } from "../exception";
33
+ import { resolvePreviousBlockHash } from "../ledger/store";
34
+ import type { LedgerStore } from "../ledger/store";
35
+ import type { ForensicLedgerService } from "../ledger/writer";
36
+
37
+ // Both identities are ordinary `did:kash` keys (base58btc, attest's
38
+ // KASH_DID_PATTERN) so the operator sign-off is a first-class ledger actor.
39
+ // The two-key guarantee comes from DISTINCTNESS, not a DID namespace: the agent
40
+ // key is the one the host holds; the operator key is SEPARATE, so the host
41
+ // cannot mint the operator (invoker) signature. Enforced below by requiring
42
+ // agent.did ≠ operator.did (their derived DIDs differ because their keys do).
43
+
44
+ export type EscalationErrorCode =
45
+ | "AGENT_DID_INVALID"
46
+ | "OPERATOR_DID_INVALID"
47
+ | "AGENT_OPERATOR_NOT_DISTINCT"
48
+ | "RESUME_ALREADY_CLAIMED";
49
+
50
+ export class EscalationError extends Error {
51
+ readonly code: EscalationErrorCode;
52
+ readonly detail: Readonly<Record<string, unknown>>;
53
+ constructor(code: EscalationErrorCode, message: string, detail: Readonly<Record<string, unknown>> = {}) {
54
+ super(`[${code}] ${message}`);
55
+ this.name = "EscalationError";
56
+ this.code = code;
57
+ this.detail = Object.freeze({ ...detail });
58
+ }
59
+ }
60
+
61
+ /** Coerce a 64-hex-ish id to the neuron schema's `0x`-prefixed lowercase-hex form. */
62
+ function asSha256Hex(s: string): Sha256Hex {
63
+ const lower = s.toLowerCase();
64
+ const hex = lower.startsWith("0x") ? lower.slice(2) : lower;
65
+ return `0x${hex}` as unknown as Sha256Hex;
66
+ }
67
+
68
+ /** Derive the real `did:kash:` binding for a public key (schema-valid DID). */
69
+ function deriveKashDid(publicKey: Uint8Array): DID {
70
+ return deriveDid(publicKey) as unknown as DID;
71
+ }
72
+
73
+ export interface BuildHumanApprovalEnvelopeInput {
74
+ readonly pendingCall: ToolCall;
75
+ readonly dagId: string;
76
+ readonly blueprintId: string;
77
+ /** The AGENT identity — the host's own key. `did` is the ledger DID (the
78
+ * signature `kid`); `publicKey` derives the schema-valid challenge binding. */
79
+ readonly agent: { readonly did: string; readonly privateKey: Uint8Array; readonly publicKey: Uint8Array };
80
+ /** The OPERATOR identity — the SEPARATE human key. */
81
+ readonly operator: { readonly did: string; readonly privateKey: Uint8Array; readonly publicKey: Uint8Array };
82
+ readonly ttlMs?: number;
83
+ }
84
+
85
+ /**
86
+ * Build the two-key `SignedActionEnvelope` that authorizes a resume of a
87
+ * restricted tool. The two signature `kid`s DIFFER by construction (agent vs
88
+ * operator), so forging human judgment would require the operator private key.
89
+ */
90
+ export async function buildHumanApprovalEnvelope(input: BuildHumanApprovalEnvelopeInput): Promise<SignedActionEnvelope> {
91
+ if (!KASH_DID_PATTERN.test(input.agent.did)) {
92
+ throw new EscalationError("AGENT_DID_INVALID", "agent.did must be a valid did:kash identifier");
93
+ }
94
+ if (!KASH_DID_PATTERN.test(input.operator.did)) {
95
+ throw new EscalationError("OPERATOR_DID_INVALID", "operator.did must be a valid did:kash identifier");
96
+ }
97
+ if (input.agent.did === input.operator.did) {
98
+ throw new EscalationError(
99
+ "AGENT_OPERATOR_NOT_DISTINCT",
100
+ "agent.did and operator.did must differ — the two-key guarantee requires a SEPARATE operator key",
101
+ );
102
+ }
103
+
104
+ // 1. AGENT attests the challenge body (proves the agent proposed it).
105
+ const attest = async (body: Readonly<Record<string, unknown>>): Promise<KashEventSignature> => {
106
+ const signed = await signEvent({}, body, input.agent.privateKey, { dst: NEURON_DST.HANDSHAKE, encoding: "hex", kid: input.agent.did });
107
+ return { alg: "ed25519", sig: signed.sig, dst: NEURON_DST.HANDSHAKE, encoding: "hex", kid: input.agent.did };
108
+ };
109
+
110
+ const gate = new PermissionGate();
111
+ const challenge = await gate.buildChallenge(
112
+ input.pendingCall,
113
+ {
114
+ agentId: deriveKashDid(input.agent.publicKey),
115
+ invokerId: deriveKashDid(input.operator.publicKey),
116
+ blueprintId: asSha256Hex(input.blueprintId),
117
+ },
118
+ attest,
119
+ { dagId: asSha256Hex(input.dagId), ...(input.ttlMs !== undefined ? { ttlMs: input.ttlMs } : {}) },
120
+ );
121
+
122
+ // 2. OPERATOR signs the SAME stripped body with the SEPARATE key.
123
+ const operatorSigned = await signEvent({}, stripDerivedFields(challenge), input.operator.privateKey, {
124
+ dst: NEURON_DST.HANDSHAKE,
125
+ encoding: "hex",
126
+ kid: input.operator.did,
127
+ });
128
+ const invokerSignature: KashEventSignature = {
129
+ alg: "ed25519",
130
+ sig: operatorSigned.sig,
131
+ dst: NEURON_DST.HANDSHAKE,
132
+ encoding: "hex",
133
+ kid: input.operator.did,
134
+ };
135
+
136
+ return assembleEnvelope(challenge, invokerSignature);
137
+ }
138
+
139
+ export interface AppendOperatorSignoffInput {
140
+ readonly forensicLedger: ForensicLedgerService;
141
+ readonly ledgerStore: LedgerStore;
142
+ readonly sessionId: string;
143
+ readonly operator: { readonly did: string; readonly privateKey: Uint8Array };
144
+ readonly escalation: { readonly suspendedStepId: string; readonly toolName: string; readonly escalationId?: string };
145
+ /** MANDATORY classification (required `failureClass`, no "unclassified"). */
146
+ readonly classification: ExceptionClassificationInput;
147
+ readonly exceptionClassificationStore: ExceptionClassificationStore;
148
+ }
149
+
150
+ /**
151
+ * Append a SIGNED ledger row recording the operator's sign-off (OPERATOR key,
152
+ * actorClass OPERATOR_ADMIN) + persist the signed exception classification.
153
+ * The mandatory `failureClass` gate fails-fast before any row is written.
154
+ */
155
+ export async function appendOperatorSignoff(
156
+ input: AppendOperatorSignoffInput,
157
+ ): Promise<{ stepIndex: number; classification: ExceptionClassificationRecord }> {
158
+ if (!KASH_DID_PATTERN.test(input.operator.did)) {
159
+ throw new EscalationError("OPERATOR_DID_INVALID", "operator.did must be a valid did:kash identifier");
160
+ }
161
+ assertFailureClass(input.classification.failureClass);
162
+
163
+ const payloadMutation: Readonly<Record<string, unknown>> = {
164
+ event: "escalation.signoff",
165
+ suspendedStepId: input.escalation.suspendedStepId,
166
+ tool: input.escalation.toolName,
167
+ failureClass: input.classification.failureClass,
168
+ failureTag: input.classification.failureTag,
169
+ };
170
+
171
+ const { previousBlockHash, nextStepIndex } = await resolvePreviousBlockHash(input.ledgerStore, input.sessionId);
172
+ const signature = await signLedgerBlock(
173
+ { sessionId: input.sessionId, stepIndex: nextStepIndex, actorDid: input.operator.did, previousBlockHash, payloadMutation },
174
+ input.operator.privateKey,
175
+ );
176
+
177
+ const block = await input.forensicLedger.appendTransitionLog({
178
+ sessionId: input.sessionId,
179
+ stepIndex: nextStepIndex,
180
+ actorDid: input.operator.did,
181
+ actorClass: "OPERATOR_ADMIN",
182
+ payloadMutation,
183
+ signature,
184
+ });
185
+
186
+ const classification = await buildSignedClassification(input.classification, input.operator, {
187
+ escalationId: input.escalation.escalationId ?? null,
188
+ signoffLedgerStepIndex: block.stepIndex,
189
+ });
190
+ await input.exceptionClassificationStore.append(classification);
191
+
192
+ return { stepIndex: block.stepIndex, classification };
193
+ }
194
+
195
+ export interface VerifyApprovalEnvelopeInput {
196
+ readonly envelope: SignedActionEnvelope;
197
+ /** The AGENT public key (the host's own key) that attested the challenge. */
198
+ readonly agentPublicKey: Uint8Array;
199
+ /** The OPERATOR public key — the SEPARATE human key the host cannot mint. */
200
+ readonly operatorPublicKey: Uint8Array;
201
+ /** If given, the envelope's proposed tool MUST equal this (bind to the suspended call). */
202
+ readonly expectedToolName?: string;
203
+ /** If given, the challenge must not be expired at this wall-clock (ms since epoch). */
204
+ readonly nowMs?: number;
205
+ }
206
+
207
+ export type ApprovalVerificationReason =
208
+ | "MISSING_SIGNATURE_KID"
209
+ | "KEYS_NOT_DISTINCT"
210
+ | "TOOL_MISMATCH"
211
+ | "WRONG_DST"
212
+ | "EXPIRED"
213
+ | "AGENT_ATTESTATION_INVALID"
214
+ | "OPERATOR_SIGNATURE_INVALID";
215
+
216
+ export interface ApprovalVerification {
217
+ readonly ok: boolean;
218
+ readonly reason: ApprovalVerificationReason | null;
219
+ }
220
+
221
+ function toSignedEvent(body: unknown, sig: KashEventSignature) {
222
+ // The envelope's signatures were minted with `encoding: "hex"` + a `kid`; the
223
+ // neuron type marks both optional, so default them for identity-core's verifier.
224
+ return {
225
+ header: {} as Readonly<Record<string, unknown>>,
226
+ body,
227
+ signature: { alg: sig.alg, sig: sig.sig, dst: sig.dst, encoding: sig.encoding ?? "hex", kid: sig.kid ?? "" },
228
+ };
229
+ }
230
+
231
+ /**
232
+ * CRYPTOGRAPHICALLY verify a two-key human-approval envelope before a suspended
233
+ * run may resume (audit r1 F1). The security-critical check is the OPERATOR
234
+ * (invoker) signature over `stripDerivedFields(challenge)` under a key the host
235
+ * does NOT hold — so the host cannot forge human judgment by merely PRESENTING an
236
+ * envelope. Also re-checks: the AGENT attestation, the two kids differ, the DST is
237
+ * the handshake DST, the proposed tool matches, and (optionally) the challenge
238
+ * has not expired. Returns `{ok:false, reason}` on any failure — never throws.
239
+ */
240
+ export async function verifyApprovalEnvelope(input: VerifyApprovalEnvelopeInput): Promise<ApprovalVerification> {
241
+ const { envelope, agentPublicKey, operatorPublicKey } = input;
242
+ const challenge = envelope.challenge;
243
+ const agentAttestation = challenge.agentAttestation;
244
+ const invokerSignature = envelope.invokerSignature;
245
+
246
+ const agentKid = agentAttestation?.kid;
247
+ const invokerKid = invokerSignature?.kid;
248
+ if (typeof agentKid !== "string" || typeof invokerKid !== "string") return { ok: false, reason: "MISSING_SIGNATURE_KID" };
249
+ // Two-key: the operator key is SEPARATE from the agent key the host holds.
250
+ if (agentKid === invokerKid) return { ok: false, reason: "KEYS_NOT_DISTINCT" };
251
+ if (input.expectedToolName !== undefined && challenge.proposedAction.toolName !== input.expectedToolName) {
252
+ return { ok: false, reason: "TOOL_MISMATCH" };
253
+ }
254
+ if (agentAttestation.dst !== NEURON_DST.HANDSHAKE || invokerSignature.dst !== NEURON_DST.HANDSHAKE) {
255
+ return { ok: false, reason: "WRONG_DST" };
256
+ }
257
+ if (input.nowMs !== undefined) {
258
+ const expiresMs = Date.parse(challenge.expiresAt);
259
+ if (Number.isFinite(expiresMs) && input.nowMs > expiresMs) return { ok: false, reason: "EXPIRED" };
260
+ }
261
+
262
+ const signable = stripDerivedFields(challenge);
263
+ const agentOk = await verifyEvent(toSignedEvent(signable, agentAttestation), agentPublicKey);
264
+ if (!agentOk) return { ok: false, reason: "AGENT_ATTESTATION_INVALID" };
265
+ // SECURITY-CRITICAL: the operator signed the SAME stripped body with the key
266
+ // the host cannot mint. This is what makes "the host cannot forge human
267
+ // judgment" literally true at RESUME time (not just at build time).
268
+ const operatorOk = await verifyEvent(toSignedEvent(signable, invokerSignature), operatorPublicKey);
269
+ if (!operatorOk) return { ok: false, reason: "OPERATOR_SIGNATURE_INVALID" };
270
+
271
+ return { ok: true, reason: null };
272
+ }
273
+
274
+ /** One-way claim guard — an escalation/resume key may be claimed AT MOST ONCE,
275
+ * so a retried resume can't double-execute a money-moving tool. In-memory
276
+ * (single process); a durable CAS is a venture concern. */
277
+ export class ResumeGuard {
278
+ private readonly claimed = new Set<string>();
279
+
280
+ /** Claim `escalationKey`. Throws `EscalationError("RESUME_ALREADY_CLAIMED")`
281
+ * if already claimed — the throw is the defense. */
282
+ claim(escalationKey: string): void {
283
+ if (this.claimed.has(escalationKey)) {
284
+ throw new EscalationError("RESUME_ALREADY_CLAIMED", `resume key '${escalationKey}' was already claimed — refusing to re-execute`, { escalationKey });
285
+ }
286
+ this.claimed.add(escalationKey);
287
+ }
288
+
289
+ isClaimed(escalationKey: string): boolean {
290
+ return this.claimed.has(escalationKey);
291
+ }
292
+ }
@@ -0,0 +1,14 @@
1
+ // @kashscript/hudhud/runtime — the accountable execution runtime: the neuron
2
+ // tool dispatcher (inline signed ledger append per tool call), the work-item
3
+ // runner (owns the neuron DAGExecutor lifecycle), and the two-key escalation
4
+ // mechanic. Ventures import hudhud + define tool bindings; they never touch raw
5
+ // neuron (ECOSYSTEM #12). The SPI types ventures implement are re-exported here.
6
+
7
+ export * from "./dispatcher";
8
+ export * from "./runner";
9
+ export * from "./escalation";
10
+
11
+ // Re-export the neuron SPI surface ventures need (so they import it via hudhud).
12
+ export type { ToolManifest, TaskDAG, AgenticSpec } from "@kashscript/neuron/schemas";
13
+ export type { LLMProvider, DispatchInput, DispatchResult, StepOutcome } from "@kashscript/neuron/kernel";
14
+ export type { SignedActionEnvelope, ToolCall } from "@kashscript/neuron/handshake";