@agilno-tech/rivet 0.1.0-alpha.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/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +59 -0
- package/bin/cli.js +42 -0
- package/dist/agents/backend-django-agent/SKILL.md +35 -0
- package/dist/agents/backend-nestjs-agent/SKILL.md +36 -0
- package/dist/agents/boss-agent/SKILL.md +28 -0
- package/dist/agents/bounded-worker/SKILL.md +28 -0
- package/dist/agents/data-performance-agent/SKILL.md +32 -0
- package/dist/agents/delivery-ticketing-agent/SKILL.md +36 -0
- package/dist/agents/devops-agent/SKILL.md +34 -0
- package/dist/agents/engineering-manager/SKILL.md +28 -0
- package/dist/agents/frontend-nextjs-agent/SKILL.md +34 -0
- package/dist/agents/frontend-web-agent/SKILL.md +31 -0
- package/dist/agents/incident-response-agent/SKILL.md +42 -0
- package/dist/agents/jest-agent/SKILL.md +77 -0
- package/dist/agents/mobile-agent/SKILL.md +32 -0
- package/dist/agents/playwright-agent/SKILL.md +54 -0
- package/dist/agents/product-design-manager/SKILL.md +27 -0
- package/dist/agents/qa-agent/SKILL.md +29 -0
- package/dist/agents/quality-manager/SKILL.md +28 -0
- package/dist/agents/vitest-agent/SKILL.md +77 -0
- package/dist/governance/pre-push-rules.md +75 -0
- package/dist/governance/prompt-hygiene.md +22 -0
- package/dist/governance/review-checklist.md +24 -0
- package/dist/governance/safety-and-data.md +27 -0
- package/dist/governance/usage-rules.md +21 -0
- package/dist/mandatory/address-pr-feedback/SKILL.md +196 -0
- package/dist/mandatory/agentic-goal/SKILL.md +42 -0
- package/dist/mandatory/agentic-status/SKILL.md +74 -0
- package/dist/mandatory/apply-design/SKILL.md +228 -0
- package/dist/mandatory/check-ac/SKILL.md +124 -0
- package/dist/mandatory/create-pr-and-commit/SKILL.md +304 -0
- package/dist/mandatory/design/SKILL.md +312 -0
- package/dist/mandatory/feature-workflow/SKILL.md +87 -0
- package/dist/mandatory/hotfix/SKILL.md +195 -0
- package/dist/mandatory/pre-push/SKILL.md +272 -0
- package/dist/mandatory/project-context/SKILL.md +180 -0
- package/dist/mandatory/release-docs/SKILL.md +191 -0
- package/dist/mandatory/review-pr/SKILL.md +323 -0
- package/dist/mandatory/security-review/SKILL.md +157 -0
- package/dist/skills/api-contract/SKILL.md +77 -0
- package/dist/skills/backend-django/SKILL.md +27 -0
- package/dist/skills/backend-nestjs/SKILL.md +65 -0
- package/dist/skills/bug-ticket-creation/SKILL.md +43 -0
- package/dist/skills/cloudwatch-troubleshooting/SKILL.md +73 -0
- package/dist/skills/database-migration/SKILL.md +97 -0
- package/dist/skills/debugging/SKILL.md +32 -0
- package/dist/skills/devops-infra/SKILL.md +26 -0
- package/dist/skills/documentation/SKILL.md +24 -0
- package/dist/skills/frontend-nextjs/SKILL.md +27 -0
- package/dist/skills/incident-postmortem/SKILL.md +38 -0
- package/dist/skills/kubernetes-troubleshooting/SKILL.md +71 -0
- package/dist/skills/mobile-react-native/SKILL.md +37 -0
- package/dist/skills/postgres-analytics/SKILL.md +29 -0
- package/dist/skills/product-jira-ticketing/SKILL.md +28 -0
- package/dist/skills/qa-bug-analysis/SKILL.md +27 -0
- package/dist/skills/refactoring/SKILL.md +32 -0
- package/dist/skills/sprint-planning/SKILL.md +32 -0
- package/dist/skills/testing-quality/SKILL.md +62 -0
- package/dist/v2/protocols/agent-orchestration.md +49 -0
- package/dist/v2/protocols/delivery.md +45 -0
- package/dist/v2/protocols/design-authority.md +29 -0
- package/dist/v2/protocols/goal-graph.md +44 -0
- package/dist/v2/protocols/pattern-first-development.md +29 -0
- package/dist/v2/protocols/qa-evidence.md +29 -0
- package/dist/v2/protocols/security.md +29 -0
- package/dist/v2/schemas/event.schema.json +86 -0
- package/dist/v2/schemas/evidence.schema.json +94 -0
- package/dist/v2/schemas/feature-decomposition.schema.json +48 -0
- package/dist/v2/schemas/feature-plan.schema.json +95 -0
- package/dist/v2/schemas/goal-graph.schema.json +61 -0
- package/dist/v2/schemas/integration.schema.json +119 -0
- package/dist/v2/schemas/orchestration.schema.json +69 -0
- package/dist/v2/schemas/project.schema.json +209 -0
- package/dist/v2/schemas/providers.schema.json +142 -0
- package/dist/v2/schemas/quality.schema.json +50 -0
- package/dist/v2/schemas/work-action.schema.json +44 -0
- package/dist/v2/schemas/work-request.schema.json +253 -0
- package/dist/v2/templates/evidence/qa-bundle.json +194 -0
- package/dist/v2/templates/github-actions/rivet-deploy.yml +98 -0
- package/dist/v2/templates/harness/SKILL.md +90 -0
- package/dist/v2/templates/project/.rivet/orchestration.yaml +42 -0
- package/dist/v2/templates/project/.rivet/project.yaml +14 -0
- package/dist/v2/templates/project/.rivet/providers.yaml +8 -0
- package/dist/v2/templates/project/.rivet/quality.yaml +16 -0
- package/package.json +58 -0
- package/protocols/agent-orchestration.md +49 -0
- package/protocols/delivery.md +45 -0
- package/protocols/design-authority.md +29 -0
- package/protocols/goal-graph.md +44 -0
- package/protocols/pattern-first-development.md +29 -0
- package/protocols/qa-evidence.md +29 -0
- package/protocols/security.md +29 -0
- package/schemas/event.schema.json +86 -0
- package/schemas/evidence.schema.json +94 -0
- package/schemas/feature-decomposition.schema.json +48 -0
- package/schemas/feature-plan.schema.json +95 -0
- package/schemas/goal-graph.schema.json +61 -0
- package/schemas/integration.schema.json +119 -0
- package/schemas/orchestration.schema.json +69 -0
- package/schemas/project.schema.json +209 -0
- package/schemas/providers.schema.json +142 -0
- package/schemas/quality.schema.json +50 -0
- package/schemas/work-action.schema.json +44 -0
- package/schemas/work-request.schema.json +253 -0
- package/src/adapters/confluence.js +138 -0
- package/src/adapters/contract.js +657 -0
- package/src/adapters/factory.js +122 -0
- package/src/adapters/figma.js +178 -0
- package/src/adapters/fixtures.js +183 -0
- package/src/adapters/github.js +314 -0
- package/src/adapters/http.js +704 -0
- package/src/adapters/jira.js +195 -0
- package/src/adapters/linear.js +171 -0
- package/src/adapters/node-transport.js +59 -0
- package/src/cli/integration-setup-prompt.js +32 -0
- package/src/cli/interrupt.js +25 -0
- package/src/cli/main.js +596 -0
- package/src/cli/output.js +174 -0
- package/src/cli/parse-args.js +246 -0
- package/src/cli/project-discovery.js +70 -0
- package/src/cli/task-confirmation.js +38 -0
- package/src/cli/task-presentation.js +22 -0
- package/src/clients/claude.js +214 -0
- package/src/clients/codex.js +180 -0
- package/src/clients/compatibility.js +23 -0
- package/src/clients/contract.js +224 -0
- package/src/clients/fake.js +170 -0
- package/src/clients/process-runner.js +657 -0
- package/src/clients/result-contract.js +78 -0
- package/src/commands/delivery-publish.js +80 -0
- package/src/commands/delivery-remote.js +230 -0
- package/src/commands/delivery-review-update.js +39 -0
- package/src/commands/delivery-tracker.js +94 -0
- package/src/commands/delivery-transition.js +79 -0
- package/src/commands/delivery.js +223 -0
- package/src/commands/dependency-approval.js +21 -0
- package/src/commands/doctor.js +191 -0
- package/src/commands/evidence.js +44 -0
- package/src/commands/feature.js +212 -0
- package/src/commands/goals.js +231 -0
- package/src/commands/human-run.js +208 -0
- package/src/commands/human-task.js +278 -0
- package/src/commands/init.js +848 -0
- package/src/commands/install.js +814 -0
- package/src/commands/integration-setup.js +65 -0
- package/src/commands/integrations.js +54 -0
- package/src/commands/models.js +158 -0
- package/src/commands/orchestrate.js +61 -0
- package/src/commands/preflight.js +125 -0
- package/src/commands/protocols.js +228 -0
- package/src/commands/repositories.js +77 -0
- package/src/commands/setup-remote.js +41 -0
- package/src/commands/setup.js +214 -0
- package/src/commands/status.js +224 -0
- package/src/commands/support.js +44 -0
- package/src/commands/task-completion.js +104 -0
- package/src/commands/uninstall.js +234 -0
- package/src/commands/verify.js +171 -0
- package/src/commands/work.js +220 -0
- package/src/commands/worktrees.js +52 -0
- package/src/config/command-readiness.js +218 -0
- package/src/config/commands.js +206 -0
- package/src/config/defaults.js +55 -0
- package/src/config/load.js +230 -0
- package/src/config/validate.js +560 -0
- package/src/delivery/bitbucket-review.js +123 -0
- package/src/delivery/branch-publication.js +23 -0
- package/src/delivery/contract.js +379 -0
- package/src/delivery/github-deployment.js +132 -0
- package/src/delivery/github-review.js +5 -0
- package/src/delivery/github.js +382 -0
- package/src/delivery/gitlab-review.js +5 -0
- package/src/delivery/gitlab.js +395 -0
- package/src/delivery/prepare.js +90 -0
- package/src/delivery/publication-process.js +33 -0
- package/src/delivery/publication-transport.js +112 -0
- package/src/delivery/review-request.js +184 -0
- package/src/delivery/review-update.js +74 -0
- package/src/delivery/service.js +400 -0
- package/src/delivery/store.js +76 -0
- package/src/delivery/tracker-target.js +51 -0
- package/src/delivery/tracker-transition.js +158 -0
- package/src/delivery/tracker.js +164 -0
- package/src/discovery/git.js +85 -0
- package/src/discovery/portable.js +108 -0
- package/src/discovery/project.js +345 -0
- package/src/discovery/tools.js +145 -0
- package/src/evaluations/approval.js +20 -0
- package/src/evaluations/cost-policy.js +20 -0
- package/src/evaluations/harness-attempt.js +113 -0
- package/src/evaluations/live-runner.js +55 -0
- package/src/evaluations/profile.js +28 -0
- package/src/evaluations/report.js +11 -0
- package/src/evaluations/text-attempt.js +18 -0
- package/src/evidence/checksum.js +87 -0
- package/src/evidence/collect.js +727 -0
- package/src/evidence/validate.js +354 -0
- package/src/feature/accepted-integration.js +46 -0
- package/src/feature/actions.js +37 -0
- package/src/feature/client-profile.js +56 -0
- package/src/feature/decomposition-contract.js +58 -0
- package/src/feature/host-execution.js +633 -0
- package/src/feature/host-lock-recovery.js +96 -0
- package/src/feature/host-run-lock.js +32 -0
- package/src/feature/local-approval.js +183 -0
- package/src/feature/plan-contract.js +215 -0
- package/src/feature/planner.js +253 -0
- package/src/feature/run-store.js +241 -0
- package/src/feature/runtime-bridge.js +840 -0
- package/src/feature/verification-report.js +168 -0
- package/src/feature/workflow.js +375 -0
- package/src/git/client.js +509 -0
- package/src/git/integration-worktree.js +153 -0
- package/src/git/reconcile.js +222 -0
- package/src/git/reservations.js +450 -0
- package/src/git/worktrees.js +478 -0
- package/src/graph/completion.js +189 -0
- package/src/graph/fixtures.js +116 -0
- package/src/graph/reducer.js +124 -0
- package/src/graph/scheduler.js +252 -0
- package/src/graph/validate.js +106 -0
- package/src/install/managed.js +579 -0
- package/src/install/project-reference.js +33 -0
- package/src/install/project-runtime.js +142 -0
- package/src/install/runtime-integrity.cjs +141 -0
- package/src/integrations/capabilities.js +69 -0
- package/src/integrations/host-observation.js +26 -0
- package/src/integrations/registry.js +56 -0
- package/src/models/delegate.js +102 -0
- package/src/models/profiles.js +53 -0
- package/src/models/protocols.js +208 -0
- package/src/models/registry.js +83 -0
- package/src/models/transport.js +59 -0
- package/src/policy/approvals.js +251 -0
- package/src/policy/authority.js +282 -0
- package/src/policy/budget.js +149 -0
- package/src/policy/command-bootstrap.js +219 -0
- package/src/policy/commands.js +803 -0
- package/src/prompts/launch-contract.js +43 -0
- package/src/prompts/planning-contract.js +92 -0
- package/src/protocols/presentation.js +53 -0
- package/src/protocols/project.js +289 -0
- package/src/quality/runner.js +497 -0
- package/src/quality/traceability.js +215 -0
- package/src/repositories/identity.js +59 -0
- package/src/repositories/index.js +2 -0
- package/src/repositories/provider.js +210 -0
- package/src/runtime/application.js +353 -0
- package/src/runtime/dependency-directory.js +33 -0
- package/src/runtime/harness-discovery.js +121 -0
- package/src/runtime/heartbeat.js +47 -0
- package/src/runtime/instance-store.js +112 -0
- package/src/runtime/orchestrator.js +1133 -0
- package/src/runtime/portable-dependencies.js +94 -0
- package/src/runtime/recovery.js +190 -0
- package/src/runtime/retry.js +54 -0
- package/src/runtime/supervisor.js +166 -0
- package/src/runtime/worktree-bootstrap.js +169 -0
- package/src/state/event-store.js +333 -0
- package/src/state/lock.js +266 -0
- package/src/state/paths.js +274 -0
- package/src/state/redact.js +106 -0
- package/src/state/snapshot-store.js +310 -0
- package/src/status/public/app.js +96 -0
- package/src/status/public/index.html +37 -0
- package/src/status/public/styles.css +50 -0
- package/src/status/server.js +323 -0
- package/src/status/view-model.js +356 -0
- package/src/support/bundle.js +195 -0
- package/src/support/failure-report.js +96 -0
- package/src/work-request/contract.js +250 -0
- package/src/work-request/host.js +78 -0
- package/src/work-request/local.js +163 -0
- package/src/work-request/tracker.js +105 -0
- package/templates/evidence/qa-bundle.json +194 -0
- package/templates/github-actions/rivet-deploy.yml +98 -0
- package/templates/harness/SKILL.md +90 -0
- package/templates/project/.rivet/orchestration.yaml +42 -0
- package/templates/project/.rivet/project.yaml +14 -0
- package/templates/project/.rivet/providers.yaml +8 -0
- package/templates/project/.rivet/quality.yaml +16 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Text-only wire protocols. Transport, authorization and cost policy belong to the runtime.
|
|
2
|
+
const HOSTS = {
|
|
3
|
+
anthropic: 'https://api.anthropic.com',
|
|
4
|
+
openai: 'https://api.openai.com',
|
|
5
|
+
gemini: 'https://generativelanguage.googleapis.com',
|
|
6
|
+
};
|
|
7
|
+
function fail() {
|
|
8
|
+
const error = new Error('Model protocol input or response is unsupported or invalid.');
|
|
9
|
+
error.code = 'ERR_MODEL_PROTOCOL';
|
|
10
|
+
error.safeMessage = error.message;
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
function ensure(value) { if (!value) fail(); }
|
|
14
|
+
function snapshot(input) {
|
|
15
|
+
let nodes = 0;
|
|
16
|
+
const copy = (value, depth = 0) => {
|
|
17
|
+
ensure(++nodes <= 20000 && depth <= 24);
|
|
18
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') return value;
|
|
19
|
+
if (typeof value === 'number') { ensure(Number.isFinite(value)); return value; }
|
|
20
|
+
ensure(value && typeof value === 'object');
|
|
21
|
+
const array = Array.isArray(value);
|
|
22
|
+
ensure(Object.getPrototypeOf(value) === (array ? Array.prototype : Object.prototype)
|
|
23
|
+
|| (!array && Object.getPrototypeOf(value) === null));
|
|
24
|
+
const output = array ? [] : {};
|
|
25
|
+
if (array) ensure(value.length <= 20000 && Object.keys(value).length === value.length);
|
|
26
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
27
|
+
if (array && key === 'length') continue;
|
|
28
|
+
ensure(typeof key === 'string' && !['__proto__', 'constructor', 'prototype'].includes(key));
|
|
29
|
+
if (array) ensure(/^(0|[1-9][0-9]*)$/.test(key) && Number(key) < value.length);
|
|
30
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
31
|
+
ensure(descriptor?.enumerable && Object.hasOwn(descriptor, 'value'));
|
|
32
|
+
output[key] = copy(descriptor.value, depth + 1);
|
|
33
|
+
}
|
|
34
|
+
return output;
|
|
35
|
+
};
|
|
36
|
+
const value = copy(input);
|
|
37
|
+
ensure(Buffer.byteLength(JSON.stringify(value)) <= 1024 * 1024);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
function tokenCount(value) { ensure(Number.isSafeInteger(value) && value >= 0); return value; }
|
|
41
|
+
function nonempty(value, maximum) {
|
|
42
|
+
ensure(typeof value === 'string' && value.trim().length > 0 && Buffer.byteLength(value) <= maximum);
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function settings(input) {
|
|
46
|
+
const value = snapshot(input);
|
|
47
|
+
ensure(Object.keys(value).every(key => ['provider','model','endpoint','credentialEnv','timeoutMs','maxOutputTokens','maxCostUsd'].includes(key)));
|
|
48
|
+
ensure(['anthropic','openai','gemini','ollama','openai-compatible'].includes(value.provider));
|
|
49
|
+
nonempty(value.model, 800);
|
|
50
|
+
ensure(!/[\u0000-\u001f\u007f]/.test(value.model));
|
|
51
|
+
ensure(Number.isSafeInteger(value.maxOutputTokens) && value.maxOutputTokens > 0 && value.maxOutputTokens <= 1000000);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function baseUrl(profile) {
|
|
55
|
+
const base = HOSTS[profile.provider];
|
|
56
|
+
if (base) {
|
|
57
|
+
ensure(profile.endpoint === undefined || profile.endpoint === base || profile.endpoint === `${base}/`);
|
|
58
|
+
return base;
|
|
59
|
+
}
|
|
60
|
+
ensure(typeof profile.endpoint === 'string' && profile.endpoint.length <= 2048 && !/[\u0000-\u0020\u007f]/.test(profile.endpoint));
|
|
61
|
+
const url = new URL(profile.endpoint);
|
|
62
|
+
const local = ['127.0.0.1','localhost','[::1]'].includes(url.hostname);
|
|
63
|
+
ensure(!url.username && !url.password && !url.search && !url.hash);
|
|
64
|
+
ensure(url.protocol === 'https:' || (url.protocol === 'http:' && local));
|
|
65
|
+
if (profile.provider === 'ollama') ensure(local && url.pathname === '/');
|
|
66
|
+
return url.href.replace(/\/$/, '');
|
|
67
|
+
}
|
|
68
|
+
export function buildModelRequest(input, prompt, credential) {
|
|
69
|
+
try {
|
|
70
|
+
const profile = settings(input);
|
|
71
|
+
nonempty(prompt, 65536);
|
|
72
|
+
const base = baseUrl(profile);
|
|
73
|
+
const headers = { 'content-type': 'application/json' };
|
|
74
|
+
if (credential !== undefined) ensure(typeof credential === 'string' && credential.length > 0
|
|
75
|
+
&& credential.length <= 8192 && !/[\u0000-\u0020\u007f]/.test(credential));
|
|
76
|
+
if (HOSTS[profile.provider]) ensure(credential !== undefined);
|
|
77
|
+
const { model, maxOutputTokens } = profile;
|
|
78
|
+
const messages = [{ role: 'user', content: prompt }];
|
|
79
|
+
let url; let body;
|
|
80
|
+
switch (profile.provider) {
|
|
81
|
+
case 'anthropic':
|
|
82
|
+
url = `${base}/v1/messages`;
|
|
83
|
+
Object.assign(headers, { 'x-api-key': credential, 'anthropic-version': '2023-06-01' });
|
|
84
|
+
body = { model, max_tokens: maxOutputTokens, messages };
|
|
85
|
+
break;
|
|
86
|
+
case 'openai':
|
|
87
|
+
url = `${base}/v1/responses`; headers.authorization = `Bearer ${credential}`;
|
|
88
|
+
body = { model, input: prompt, max_output_tokens: maxOutputTokens, store: false };
|
|
89
|
+
break;
|
|
90
|
+
case 'gemini':
|
|
91
|
+
url = `${base}/v1beta/models/${encodeURIComponent(model)}:generateContent`;
|
|
92
|
+
headers['x-goog-api-key'] = credential;
|
|
93
|
+
body = { contents: [{ role: 'user', parts: [{ text: prompt }] }], generationConfig: { maxOutputTokens } };
|
|
94
|
+
break;
|
|
95
|
+
case 'ollama':
|
|
96
|
+
url = `${base}/api/chat`;
|
|
97
|
+
body = { model, messages, stream: false, options: { num_predict: maxOutputTokens } };
|
|
98
|
+
break;
|
|
99
|
+
case 'openai-compatible':
|
|
100
|
+
url = `${base}/chat/completions`;
|
|
101
|
+
if (credential !== undefined) headers.authorization = `Bearer ${credential}`;
|
|
102
|
+
body = { model, messages, max_tokens: maxOutputTokens, stream: false };
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
return { url, headers, body };
|
|
106
|
+
} catch { fail(); }
|
|
107
|
+
}
|
|
108
|
+
function fields(value, allowed) {
|
|
109
|
+
ensure(value && typeof value === 'object' && !Array.isArray(value));
|
|
110
|
+
ensure(Object.keys(value).every(key => allowed.includes(key)));
|
|
111
|
+
}
|
|
112
|
+
function noCalls(message) {
|
|
113
|
+
ensure(message && message.role === 'assistant');
|
|
114
|
+
ensure(message.tool_calls === undefined || (Array.isArray(message.tool_calls) && message.tool_calls.length === 0));
|
|
115
|
+
ensure(message.function_call === undefined || message.function_call === null);
|
|
116
|
+
ensure(message.refusal === undefined || message.refusal === null);
|
|
117
|
+
}
|
|
118
|
+
export function parseModelResponse(input, data) {
|
|
119
|
+
try {
|
|
120
|
+
const profile = settings(input);
|
|
121
|
+
const response = snapshot(data);
|
|
122
|
+
ensure(response && typeof response === 'object' && !Array.isArray(response) && !response.error);
|
|
123
|
+
let text; let model = response.model; let inputTokens; let outputTokens;
|
|
124
|
+
switch (profile.provider) {
|
|
125
|
+
case 'anthropic': {
|
|
126
|
+
ensure(response.type === 'message' && response.role === 'assistant' && response.stop_reason === 'end_turn');
|
|
127
|
+
ensure(Array.isArray(response.content) && response.content.length > 0);
|
|
128
|
+
text = response.content.map(part => {
|
|
129
|
+
fields(part, ['type','text','citations']);
|
|
130
|
+
ensure(part.type === 'text'); return nonempty(part.text, 262144);
|
|
131
|
+
}).join('');
|
|
132
|
+
inputTokens = tokenCount(response.usage?.input_tokens)
|
|
133
|
+
+ tokenCount(response.usage?.cache_creation_input_tokens ?? 0)
|
|
134
|
+
+ tokenCount(response.usage?.cache_read_input_tokens ?? 0);
|
|
135
|
+
outputTokens = tokenCount(response.usage?.output_tokens);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case 'openai': {
|
|
139
|
+
ensure(response.status === 'completed' && !response.error && !response.incomplete_details);
|
|
140
|
+
ensure(Array.isArray(response.output) && response.output.length > 0);
|
|
141
|
+
text = response.output.map(item => {
|
|
142
|
+
if (item.type === 'reasoning') {
|
|
143
|
+
fields(item, ['type','id','summary','content','encrypted_content','status']);
|
|
144
|
+
ensure(item.status === undefined || item.status === 'completed');
|
|
145
|
+
if (item.summary !== undefined) { ensure(Array.isArray(item.summary)); for (const part of item.summary) { fields(part,['type','text']);ensure(part.type === 'summary_text' && typeof part.text === 'string'); } }
|
|
146
|
+
if (item.content !== undefined) { ensure(Array.isArray(item.content));for (const part of item.content) { fields(part,['type','text']);ensure(part.type === 'reasoning_text' && typeof part.text === 'string'); } }
|
|
147
|
+
return '';
|
|
148
|
+
}
|
|
149
|
+
fields(item, ['type','id','status','role','content','phase']);
|
|
150
|
+
ensure(item.type === 'message' && item.role === 'assistant' && item.status === 'completed');
|
|
151
|
+
ensure(Array.isArray(item.content) && item.content.length > 0);
|
|
152
|
+
return item.content.map(part => {
|
|
153
|
+
fields(part,['type','text','annotations','logprobs']);
|
|
154
|
+
ensure(part.type === 'output_text'); return nonempty(part.text,262144);
|
|
155
|
+
}).join('');
|
|
156
|
+
}).join('');
|
|
157
|
+
inputTokens = tokenCount(response.usage?.input_tokens);
|
|
158
|
+
outputTokens = tokenCount(response.usage?.output_tokens);
|
|
159
|
+
if (response.usage?.output_tokens_details?.reasoning_tokens !== undefined) {
|
|
160
|
+
ensure(tokenCount(response.usage.output_tokens_details.reasoning_tokens) <= outputTokens);
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case 'gemini': {
|
|
165
|
+
ensure(!response.promptFeedback?.blockReason && Array.isArray(response.candidates) && response.candidates.length === 1);
|
|
166
|
+
const candidate = response.candidates[0];
|
|
167
|
+
ensure(candidate.finishReason === 'STOP' && candidate.content?.role === 'model' && Array.isArray(candidate.content.parts));
|
|
168
|
+
ensure(!candidate.safetyRatings?.some(rating => rating.blocked === true));
|
|
169
|
+
if (candidate.content.parts.some(part => part.thought === true)) tokenCount(response.usageMetadata?.thoughtsTokenCount);
|
|
170
|
+
text = candidate.content.parts.map(part => {
|
|
171
|
+
fields(part,['text','thought','thoughtSignature']);
|
|
172
|
+
ensure(part.thought === undefined || typeof part.thought === 'boolean');
|
|
173
|
+
const content = nonempty(part.text,262144);return part.thought === true ? '' : content;
|
|
174
|
+
}).join('');
|
|
175
|
+
model = response.modelVersion;
|
|
176
|
+
inputTokens = tokenCount(response.usageMetadata?.promptTokenCount);
|
|
177
|
+
outputTokens = tokenCount(response.usageMetadata?.candidatesTokenCount) + tokenCount(response.usageMetadata?.thoughtsTokenCount ?? 0);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case 'ollama':
|
|
181
|
+
ensure(response.done === true && response.done_reason === 'stop');
|
|
182
|
+
fields(response.message, ['role','content','thinking','tool_calls']);
|
|
183
|
+
noCalls(response.message);
|
|
184
|
+
ensure(response.message.thinking === undefined || typeof response.message.thinking === 'string');
|
|
185
|
+
text = response.message.content;
|
|
186
|
+
inputTokens = tokenCount(response.prompt_eval_count); outputTokens = tokenCount(response.eval_count);
|
|
187
|
+
break;
|
|
188
|
+
case 'openai-compatible': {
|
|
189
|
+
ensure(Array.isArray(response.choices) && response.choices.length === 1);
|
|
190
|
+
const choice = response.choices[0];
|
|
191
|
+
ensure(choice.finish_reason === 'stop'); noCalls(choice.message);
|
|
192
|
+
fields(choice.message,['role','content','refusal','tool_calls','function_call','annotations','audio']);
|
|
193
|
+
ensure(choice.message.audio === undefined || choice.message.audio === null);
|
|
194
|
+
text = choice.message.content;
|
|
195
|
+
inputTokens = tokenCount(response.usage?.prompt_tokens); outputTokens = tokenCount(response.usage?.completion_tokens);
|
|
196
|
+
if (response.usage?.completion_tokens_details?.reasoning_tokens !== undefined) {
|
|
197
|
+
ensure(tokenCount(response.usage.completion_tokens_details.reasoning_tokens) <= outputTokens);
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
nonempty(text,262144); nonempty(model,800);
|
|
203
|
+
ensure(!/[\u0000-\u001f\u007f]/.test(model));
|
|
204
|
+
tokenCount(inputTokens); tokenCount(outputTokens);
|
|
205
|
+
ensure(outputTokens <= profile.maxOutputTokens);
|
|
206
|
+
return { text, model, usage: { inputTokens, outputTokens } };
|
|
207
|
+
} catch { fail(); }
|
|
208
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const ID = /^[a-z][a-z0-9-]{0,63}$/;
|
|
2
|
+
const ENV = /^[A-Z][A-Z0-9_]{1,127}$/;
|
|
3
|
+
const BUILTINS = [
|
|
4
|
+
{ id: 'anthropic', label: 'Anthropic API', kind: 'api', protocol: 'anthropic-messages' },
|
|
5
|
+
{ id: 'openai', label: 'OpenAI API', kind: 'api', protocol: 'openai-responses' },
|
|
6
|
+
{ id: 'gemini', label: 'Google Gemini API', kind: 'api', protocol: 'gemini-content' },
|
|
7
|
+
{ id: 'ollama', label: 'Ollama local models', kind: 'local', protocol: 'ollama-chat', allowLocalHttp: true },
|
|
8
|
+
{ id: 'openai-compatible', label: 'OpenAI-compatible endpoint', kind: 'api', protocol: 'openai-chat', allowLocalHttp: true },
|
|
9
|
+
{ id: 'claude-code', label: 'Claude Code harness', kind: 'harness', protocol: 'claude-cli', execution: 'adapter-available' },
|
|
10
|
+
{ id: 'codex', label: 'Codex harness', kind: 'harness', protocol: 'codex-cli', execution: 'adapter-available' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function record(input, allowed, message) {
|
|
14
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)
|
|
15
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(input))) throw new TypeError(message);
|
|
16
|
+
const output = {};
|
|
17
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
18
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, key);
|
|
19
|
+
if (!allowed.includes(key) || !descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) throw new TypeError(message);
|
|
20
|
+
output[key] = descriptor.value;
|
|
21
|
+
}
|
|
22
|
+
return output;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function descriptor(input) {
|
|
26
|
+
const message = 'Invalid model adapter';
|
|
27
|
+
const value = record(input, ['id', 'label', 'kind', 'protocol', 'capabilities', 'execution', 'allowLocalHttp'], message);
|
|
28
|
+
if (typeof value.id !== 'string' || !ID.test(value.id) || typeof value.label !== 'string' || !value.label.trim() || value.label.length > 120
|
|
29
|
+
|| /[\u0000-\u001f\u007f]/.test(value.label)
|
|
30
|
+
|| !['api', 'local', 'harness'].includes(value.kind) || typeof value.protocol !== 'string' || !ID.test(value.protocol)
|
|
31
|
+
|| !['planned', 'adapter-available'].includes(value.execution)
|
|
32
|
+
|| (value.allowLocalHttp !== undefined && typeof value.allowLocalHttp !== 'boolean')
|
|
33
|
+
|| !Array.isArray(value.capabilities) || value.capabilities.length < 1 || value.capabilities.length > 32
|
|
34
|
+
|| value.capabilities.some(item => typeof item !== 'string' || !ID.test(item))
|
|
35
|
+
|| new Set(value.capabilities).size !== value.capabilities.length) throw new TypeError(message);
|
|
36
|
+
return Object.freeze({ ...value, capabilities: Object.freeze([...value.capabilities]), liveVerified: false });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function endpoint(value, provider) {
|
|
40
|
+
try {
|
|
41
|
+
if (typeof value !== 'string' || value.length > 2048 || /[\u0000-\u0020\u007f]/.test(value)) throw new Error();
|
|
42
|
+
const url = new URL(value);
|
|
43
|
+
const local = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
|
|
44
|
+
if (url.username || url.password || url.search || url.hash
|
|
45
|
+
|| (url.protocol !== 'https:' && !(url.protocol === 'http:' && local && provider.allowLocalHttp))) throw new Error();
|
|
46
|
+
return url.href;
|
|
47
|
+
} catch { throw new TypeError('Invalid model profile'); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createModelRegistry() {
|
|
51
|
+
const providers = new Map();
|
|
52
|
+
const register = input => {
|
|
53
|
+
const provider = descriptor(input);
|
|
54
|
+
if (providers.has(provider.id)) throw new TypeError('Model adapter already registered');
|
|
55
|
+
providers.set(provider.id, provider);
|
|
56
|
+
return provider;
|
|
57
|
+
};
|
|
58
|
+
for (const provider of BUILTINS) register({ capabilities: ['text'], execution: 'adapter-available', ...provider });
|
|
59
|
+
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
register,
|
|
62
|
+
list: () => Object.freeze([...providers.values()]),
|
|
63
|
+
resolve(input, options = {}) {
|
|
64
|
+
const message = 'Invalid model profile';
|
|
65
|
+
const value = record(input, ['provider', 'model', 'endpoint', 'credentialEnv', 'timeoutMs', 'maxOutputTokens', 'maxCostUsd'], message);
|
|
66
|
+
const provider = providers.get(value.provider);
|
|
67
|
+
if (!provider || typeof value.model !== 'string' || !value.model.trim() || value.model.length > 200
|
|
68
|
+
|| /[\u0000-\u001f\u007f]/.test(value.model)
|
|
69
|
+
|| !Number.isSafeInteger(value.timeoutMs) || value.timeoutMs < 1 || value.timeoutMs > 3_600_000
|
|
70
|
+
|| !Number.isSafeInteger(value.maxOutputTokens) || value.maxOutputTokens < 1 || value.maxOutputTokens > 1_000_000
|
|
71
|
+
|| (value.credentialEnv !== undefined && (typeof value.credentialEnv !== 'string' || !ENV.test(value.credentialEnv)))
|
|
72
|
+
|| (value.maxCostUsd !== undefined && (!Number.isFinite(value.maxCostUsd) || value.maxCostUsd <= 0 || value.maxCostUsd > 10_000))) throw new TypeError(message);
|
|
73
|
+
const settings = record(options, ['requiredCapabilities'], message);
|
|
74
|
+
const required = settings.requiredCapabilities ?? [];
|
|
75
|
+
if (!Array.isArray(required) || required.length > 32 || required.some(item => typeof item !== 'string' || !ID.test(item))) throw new TypeError(message);
|
|
76
|
+
if (required.some(capability => !provider.capabilities.includes(capability))) throw new TypeError('Missing model capability');
|
|
77
|
+
const profile = Object.freeze({ ...value, ...(value.endpoint === undefined ? {} : { endpoint: endpoint(value.endpoint, provider) }) });
|
|
78
|
+
// Registry validation is not an authentication, executable, or live model probe.
|
|
79
|
+
const reasons = Object.freeze([provider.execution === 'planned' ? 'adapter-not-implemented' : 'requires-runtime-check']);
|
|
80
|
+
return Object.freeze({ provider, profile, ready: false, reasons });
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { request as httpRequest } from 'node:http';
|
|
2
|
+
import { createProviderHttpClient } from '../adapters/http.js';
|
|
3
|
+
import { createNodeProviderTransport } from '../adapters/node-transport.js';
|
|
4
|
+
import { createModelJsonBody, providerWireBytes } from '../adapters/contract.js';
|
|
5
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
6
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
7
|
+
|
|
8
|
+
// Local model calls deliberately bypass public-only DNS policy, but only for an
|
|
9
|
+
// explicitly selected HTTP loopback destination. No DNS, proxy, redirect or Unix
|
|
10
|
+
// socket selection is exposed. HTTPS loopback is not supported in this slice.
|
|
11
|
+
async function loopbackJson(url, headers, body, signal) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
if (signal.aborted) { reject(new Error('Model request cancelled')); return; }
|
|
14
|
+
const request = httpRequest({
|
|
15
|
+
protocol: 'http:', hostname: url.hostname === '[::1]' ? '::1' : '127.0.0.1',
|
|
16
|
+
port: url.port || 80, method: 'POST', path: url.pathname,
|
|
17
|
+
headers: { ...headers, host: url.host, 'accept-encoding': 'identity' }, agent: false, signal,
|
|
18
|
+
}, response => {
|
|
19
|
+
const fail = () => { response.destroy(); reject(new Error('Invalid model response')); };
|
|
20
|
+
if (!Number.isInteger(response.statusCode) || response.statusCode < 200 || response.statusCode >= 300
|
|
21
|
+
|| !/^application\/(?:[A-Za-z0-9.+-]+\+)?json(?:\s*;|$)/i.test(response.headers['content-type'] ?? '')
|
|
22
|
+
|| (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity')) { fail(); return; }
|
|
23
|
+
const length = response.headers['content-length'];
|
|
24
|
+
if (length !== undefined && (!/^\d+$/.test(length) || Number(length) > MAX_RESPONSE_BYTES)) { fail(); return; }
|
|
25
|
+
const chunks = []; let bytes = 0;
|
|
26
|
+
response.on('data', chunk => {
|
|
27
|
+
bytes += chunk.length;
|
|
28
|
+
if (bytes > MAX_RESPONSE_BYTES) { fail(); return; }
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
});
|
|
31
|
+
response.on('error', reject);
|
|
32
|
+
response.on('aborted', () => reject(new Error('Incomplete model response')));
|
|
33
|
+
response.on('end', () => {
|
|
34
|
+
if (bytes > MAX_RESPONSE_BYTES) return;
|
|
35
|
+
try { resolve(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)))); }
|
|
36
|
+
catch { reject(new Error('Invalid model response')); }
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
request.on('error', reject);
|
|
40
|
+
request.end(body);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function requestModelJson({ provider, request, timeoutMs, signal, transport }) {
|
|
45
|
+
const url = new URL(request.url);
|
|
46
|
+
if (url.username || url.password || url.search || url.hash) throw new Error('Invalid model destination');
|
|
47
|
+
const wireBody = createModelJsonBody({ provider, payload: request.body });
|
|
48
|
+
if (LOOPBACK.has(url.hostname)) {
|
|
49
|
+
if (!['ollama', 'openai-compatible'].includes(provider) || url.protocol !== 'http:' || transport !== undefined)
|
|
50
|
+
throw new Error('Unsupported local model destination');
|
|
51
|
+
return loopbackJson(url, request.headers, providerWireBytes(wireBody), signal);
|
|
52
|
+
}
|
|
53
|
+
if (url.protocol !== 'https:') throw new Error('Invalid model destination');
|
|
54
|
+
const http = createProviderHttpClient({
|
|
55
|
+
provider, baseUrl: url.origin, transport: transport ?? createNodeProviderTransport(),
|
|
56
|
+
headers: request.headers, timeoutMs, maxResponseBytes: MAX_RESPONSE_BYTES,
|
|
57
|
+
});
|
|
58
|
+
return (await http.request({ method: 'POST', path: url.pathname, wireBody, signal })).data;
|
|
59
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
const APPROVAL_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
2
|
+
const ACTION_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
|
3
|
+
const receipts = new WeakSet();
|
|
4
|
+
const registries = new WeakMap();
|
|
5
|
+
|
|
6
|
+
export class ApprovalPolicyError extends Error {
|
|
7
|
+
constructor(reason = 'invalid-approval') {
|
|
8
|
+
super('Approval policy input is invalid.');
|
|
9
|
+
this.name = 'ApprovalPolicyError';
|
|
10
|
+
this.code = 'ERR_APPROVAL_POLICY';
|
|
11
|
+
this.safeMessage = this.message;
|
|
12
|
+
this.details = Object.freeze({ reason });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fail(reason) { throw new ApprovalPolicyError(reason); }
|
|
17
|
+
|
|
18
|
+
function captureObject(value, allowed, required, reason) {
|
|
19
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail(reason);
|
|
20
|
+
let keys;
|
|
21
|
+
try { keys = Reflect.ownKeys(value); } catch { fail(reason); }
|
|
22
|
+
if (keys.some(key => typeof key !== 'string' || !allowed.has(key))) fail(reason);
|
|
23
|
+
const result = Object.create(null);
|
|
24
|
+
try {
|
|
25
|
+
for (const key of keys) {
|
|
26
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
27
|
+
if (!descriptor?.enumerable) fail(reason);
|
|
28
|
+
result[key] = value[key];
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error instanceof ApprovalPolicyError) throw error;
|
|
32
|
+
fail(reason);
|
|
33
|
+
}
|
|
34
|
+
if (required.some(key => !Object.hasOwn(result, key))) fail(reason);
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validId(value) {
|
|
39
|
+
return typeof value === 'string' && value.length <= 64 && APPROVAL_ID.test(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validAction(value) {
|
|
43
|
+
return typeof value === 'string' && value.length <= 100 && ACTION_ID.test(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validResource(value) {
|
|
47
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 1_024 && !/[\u0000\r\n]/.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function timestampMs(value) {
|
|
51
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) fail('invalid-expiry');
|
|
52
|
+
const result = Date.parse(value);
|
|
53
|
+
if (!Number.isSafeInteger(result) || new Date(result).toISOString() !== value) fail('invalid-expiry');
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fingerprint(receipt) {
|
|
58
|
+
return JSON.stringify([
|
|
59
|
+
receipt.id, receipt.approverId, receipt.approverPrincipal, receipt.subjectId,
|
|
60
|
+
receipt.action, receipt.resource, receipt.policyId, receipt.decision,
|
|
61
|
+
receipt.expiresAt, receipt.singleUse,
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createApprovalRegistry(input) {
|
|
66
|
+
try {
|
|
67
|
+
const value = captureObject(input, new Set(['approvers']), ['approvers'], 'invalid-registry');
|
|
68
|
+
if (!Array.isArray(value.approvers)) fail('invalid-registry');
|
|
69
|
+
const length = value.approvers.length;
|
|
70
|
+
if (!Number.isSafeInteger(length) || length < 1 || length > 64) fail('invalid-registry');
|
|
71
|
+
const approvers = new Map();
|
|
72
|
+
for (let index = 0; index < length; index += 1) {
|
|
73
|
+
if (!Object.hasOwn(value.approvers, index)) fail('invalid-registry');
|
|
74
|
+
const approver = captureObject(value.approvers[index], new Set(['id', 'principal']), ['id', 'principal'], 'invalid-registry');
|
|
75
|
+
if (!validId(approver.id) || !['human', 'agent'].includes(approver.principal) || approvers.has(approver.id)) fail('invalid-registry');
|
|
76
|
+
approvers.set(approver.id, approver.principal);
|
|
77
|
+
}
|
|
78
|
+
const registry = Object.freeze(Object.create(null));
|
|
79
|
+
registries.set(registry, { approvers, records: new Map(), consumed: new Set(), pending: new Map() });
|
|
80
|
+
return registry;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof ApprovalPolicyError) throw error;
|
|
83
|
+
fail('invalid-registry');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createApprovalReceipt(input) {
|
|
88
|
+
try {
|
|
89
|
+
const value = captureObject(input, new Set([
|
|
90
|
+
'id', 'approverId', 'approverPrincipal', 'subjectId', 'action', 'resource', 'policyId',
|
|
91
|
+
'decision', 'expiresAt', 'singleUse',
|
|
92
|
+
]), [
|
|
93
|
+
'id', 'approverId', 'approverPrincipal', 'subjectId', 'action', 'resource', 'policyId',
|
|
94
|
+
'decision', 'expiresAt', 'singleUse',
|
|
95
|
+
], 'invalid-approval');
|
|
96
|
+
if (!validId(value.id) || !validId(value.approverId) || !validId(value.subjectId)) fail('invalid-identity');
|
|
97
|
+
if (!['human', 'agent'].includes(value.approverPrincipal)) fail('invalid-approver-principal');
|
|
98
|
+
if (!validAction(value.action) || !validAction(value.policyId)) fail('invalid-policy-binding');
|
|
99
|
+
if (!validResource(value.resource)) fail('invalid-resource');
|
|
100
|
+
if (!['approved', 'rejected'].includes(value.decision)) fail('invalid-decision');
|
|
101
|
+
timestampMs(value.expiresAt);
|
|
102
|
+
if (typeof value.singleUse !== 'boolean') fail('invalid-single-use');
|
|
103
|
+
if (value.approverId === value.subjectId) fail('self-approval');
|
|
104
|
+
const receipt = Object.freeze({
|
|
105
|
+
id: value.id,
|
|
106
|
+
approverId: value.approverId,
|
|
107
|
+
approverPrincipal: value.approverPrincipal,
|
|
108
|
+
subjectId: value.subjectId,
|
|
109
|
+
action: value.action,
|
|
110
|
+
resource: value.resource,
|
|
111
|
+
policyId: value.policyId,
|
|
112
|
+
decision: value.decision,
|
|
113
|
+
expiresAt: value.expiresAt,
|
|
114
|
+
singleUse: value.singleUse,
|
|
115
|
+
});
|
|
116
|
+
receipts.add(receipt);
|
|
117
|
+
return receipt;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error instanceof ApprovalPolicyError) throw error;
|
|
120
|
+
fail('invalid-approval');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function result(valid, reason, receiptId) {
|
|
125
|
+
return Object.freeze({ valid, reason, ...(receiptId ? { receiptId } : {}) });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function verifyApproval(receipt, requestInput, optionsInput) {
|
|
129
|
+
try {
|
|
130
|
+
if (!receipts.has(receipt)) return result(false, 'untrusted-approval');
|
|
131
|
+
const request = captureObject(requestInput, new Set([
|
|
132
|
+
'subjectId', 'action', 'resource', 'policyId',
|
|
133
|
+
]), ['subjectId', 'action', 'resource', 'policyId'], 'invalid-request');
|
|
134
|
+
const options = captureObject(optionsInput, new Set([
|
|
135
|
+
'registry', 'expectedApproverId', 'requireHumanApprover', 'requireSingleUse', 'nowMs',
|
|
136
|
+
]), ['registry', 'expectedApproverId', 'requireHumanApprover', 'nowMs'], 'invalid-options');
|
|
137
|
+
const registry = registries.get(options.registry);
|
|
138
|
+
if (!registry) fail('invalid-approval-registry');
|
|
139
|
+
if (!validId(options.expectedApproverId) || typeof options.requireHumanApprover !== 'boolean') fail('invalid-approval-authority');
|
|
140
|
+
if (options.requireSingleUse !== undefined && typeof options.requireSingleUse !== 'boolean') fail('invalid-approval-authority');
|
|
141
|
+
if (!Number.isSafeInteger(options.nowMs) || options.nowMs < 0) fail('invalid-clock');
|
|
142
|
+
if (!validId(request.subjectId) || !validAction(request.action) || !validAction(request.policyId) || !validResource(request.resource)) fail('invalid-request');
|
|
143
|
+
|
|
144
|
+
const receiptFingerprint = fingerprint(receipt);
|
|
145
|
+
const registered = registry.records.get(receipt.id);
|
|
146
|
+
if (registered !== undefined && registered !== receiptFingerprint) return result(false, 'approval-id-collision', receipt.id);
|
|
147
|
+
if (receipt.singleUse && registry.consumed.has(receipt.id)) return result(false, 'approval-consumed', receipt.id);
|
|
148
|
+
if (registry.pending.has(receipt.id)) return result(false, 'approval-pending', receipt.id);
|
|
149
|
+
if (receipt.decision !== 'approved') return result(false, 'approval-rejected', receipt.id);
|
|
150
|
+
if (timestampMs(receipt.expiresAt) <= options.nowMs) return result(false, 'approval-expired', receipt.id);
|
|
151
|
+
if (receipt.approverId !== options.expectedApproverId) return result(false, 'approval-authority-mismatch', receipt.id);
|
|
152
|
+
const configuredPrincipal = registry.approvers.get(options.expectedApproverId);
|
|
153
|
+
if (!configuredPrincipal || configuredPrincipal !== receipt.approverPrincipal) return result(false, 'approval-authority-mismatch', receipt.id);
|
|
154
|
+
if (options.requireHumanApprover && configuredPrincipal !== 'human') return result(false, 'approval-authority-mismatch', receipt.id);
|
|
155
|
+
if (options.requireSingleUse && !receipt.singleUse) return result(false, 'approval-must-be-single-use', receipt.id);
|
|
156
|
+
if (
|
|
157
|
+
receipt.subjectId !== request.subjectId || receipt.action !== request.action
|
|
158
|
+
|| receipt.resource !== request.resource || receipt.policyId !== request.policyId
|
|
159
|
+
) return result(false, 'approval-binding-mismatch', receipt.id);
|
|
160
|
+
|
|
161
|
+
registry.records.set(receipt.id, receiptFingerprint);
|
|
162
|
+
if (receipt.singleUse) registry.consumed.add(receipt.id);
|
|
163
|
+
return result(true, 'approval-valid', receipt.id);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof ApprovalPolicyError) throw error;
|
|
166
|
+
fail('invalid-approval');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function claimApproval(receipt, requestInput, optionsInput) {
|
|
171
|
+
try {
|
|
172
|
+
if (!receipts.has(receipt)) return result(false, 'untrusted-approval');
|
|
173
|
+
const request = captureObject(requestInput, new Set([
|
|
174
|
+
'subjectId', 'action', 'resource', 'policyId',
|
|
175
|
+
]), ['subjectId', 'action', 'resource', 'policyId'], 'invalid-request');
|
|
176
|
+
const options = captureObject(optionsInput, new Set([
|
|
177
|
+
'registry', 'expectedApproverId', 'requireHumanApprover', 'requireSingleUse', 'nowMs',
|
|
178
|
+
]), ['registry', 'expectedApproverId', 'requireHumanApprover', 'nowMs'], 'invalid-options');
|
|
179
|
+
const registry = registries.get(options.registry);
|
|
180
|
+
if (!registry) fail('invalid-approval-registry');
|
|
181
|
+
if (!validId(options.expectedApproverId) || typeof options.requireHumanApprover !== 'boolean') fail('invalid-approval-authority');
|
|
182
|
+
if (options.requireSingleUse !== undefined && typeof options.requireSingleUse !== 'boolean') fail('invalid-approval-authority');
|
|
183
|
+
if (!Number.isSafeInteger(options.nowMs) || options.nowMs < 0) fail('invalid-clock');
|
|
184
|
+
if (!validId(request.subjectId) || !validAction(request.action) || !validAction(request.policyId) || !validResource(request.resource)) fail('invalid-request');
|
|
185
|
+
|
|
186
|
+
const receiptFingerprint = fingerprint(receipt);
|
|
187
|
+
const registered = registry.records.get(receipt.id);
|
|
188
|
+
if (registered !== undefined && registered !== receiptFingerprint) return result(false, 'approval-id-collision', receipt.id);
|
|
189
|
+
if (receipt.singleUse && registry.consumed.has(receipt.id)) return result(false, 'approval-consumed', receipt.id);
|
|
190
|
+
if (registry.pending.has(receipt.id)) return result(false, 'approval-pending', receipt.id);
|
|
191
|
+
if (receipt.decision !== 'approved') return result(false, 'approval-rejected', receipt.id);
|
|
192
|
+
if (timestampMs(receipt.expiresAt) <= options.nowMs) return result(false, 'approval-expired', receipt.id);
|
|
193
|
+
if (receipt.approverId !== options.expectedApproverId) return result(false, 'approval-authority-mismatch', receipt.id);
|
|
194
|
+
const configuredPrincipal = registry.approvers.get(options.expectedApproverId);
|
|
195
|
+
if (!configuredPrincipal || configuredPrincipal !== receipt.approverPrincipal) return result(false, 'approval-authority-mismatch', receipt.id);
|
|
196
|
+
if (options.requireHumanApprover && configuredPrincipal !== 'human') return result(false, 'approval-authority-mismatch', receipt.id);
|
|
197
|
+
if (options.requireSingleUse && !receipt.singleUse) return result(false, 'approval-must-be-single-use', receipt.id);
|
|
198
|
+
if (receipt.subjectId !== request.subjectId || receipt.action !== request.action
|
|
199
|
+
|| receipt.resource !== request.resource || receipt.policyId !== request.policyId) {
|
|
200
|
+
return result(false, 'approval-binding-mismatch', receipt.id);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const token = Object.freeze(Object.create(null));
|
|
204
|
+
registry.pending.set(receipt.id, token);
|
|
205
|
+
let active = true;
|
|
206
|
+
let finalized = false;
|
|
207
|
+
const priorRecord = registry.records.get(receipt.id);
|
|
208
|
+
const priorConsumed = registry.consumed.has(receipt.id);
|
|
209
|
+
const claim = {
|
|
210
|
+
valid: true,
|
|
211
|
+
reason: 'approval-claimed',
|
|
212
|
+
receiptId: receipt.id,
|
|
213
|
+
finalize() {
|
|
214
|
+
if (!active || finalized || registry.pending.get(receipt.id) !== token) return false;
|
|
215
|
+
registry.records.set(receipt.id, receiptFingerprint);
|
|
216
|
+
if (receipt.singleUse) registry.consumed.add(receipt.id);
|
|
217
|
+
finalized = true;
|
|
218
|
+
return true;
|
|
219
|
+
},
|
|
220
|
+
publish() {
|
|
221
|
+
if (!active || !finalized || registry.pending.get(receipt.id) !== token) return false;
|
|
222
|
+
registry.pending.delete(receipt.id);
|
|
223
|
+
active = false;
|
|
224
|
+
return true;
|
|
225
|
+
},
|
|
226
|
+
rollback() {
|
|
227
|
+
if (!active || !finalized || registry.pending.get(receipt.id) !== token) return false;
|
|
228
|
+
if (priorRecord === undefined) registry.records.delete(receipt.id);
|
|
229
|
+
else registry.records.set(receipt.id, priorRecord);
|
|
230
|
+
if (!priorConsumed) registry.consumed.delete(receipt.id);
|
|
231
|
+
registry.pending.delete(receipt.id);
|
|
232
|
+
active = false;
|
|
233
|
+
return true;
|
|
234
|
+
},
|
|
235
|
+
commit() {
|
|
236
|
+
if (!this.finalize()) return false;
|
|
237
|
+
return this.publish();
|
|
238
|
+
},
|
|
239
|
+
release() {
|
|
240
|
+
if (!active || finalized || registry.pending.get(receipt.id) !== token) return false;
|
|
241
|
+
registry.pending.delete(receipt.id);
|
|
242
|
+
active = false;
|
|
243
|
+
return true;
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
return Object.freeze(claim);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error instanceof ApprovalPolicyError) throw error;
|
|
249
|
+
fail('invalid-approval');
|
|
250
|
+
}
|
|
251
|
+
}
|