@yiaany/ghostapi 0.1.9 → 0.2.1
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/CHANGELOG.md +21 -0
- package/CONTRIBUTING.md +132 -119
- package/LICENSE +21 -21
- package/PROVENANCE.md +13 -0
- package/README.md +78 -84
- package/SECURITY.md +56 -53
- package/dist/actions/gateway.js +320 -72
- package/dist/actions/gateway.js.map +1 -1
- package/dist/actions/index.d.ts +2 -2
- package/dist/actions/index.js +1 -1
- package/dist/actions/index.js.map +1 -1
- package/dist/agents/agentPrompt.js +7 -5
- package/dist/agents/agentPrompt.js.map +1 -1
- package/dist/ai/aiClient.js +5 -5
- package/dist/ai/aiClient.js.map +1 -1
- package/dist/ai/aiGenerator.js +66 -26
- package/dist/ai/aiGenerator.js.map +1 -1
- package/dist/ai/genericInference.js +151 -36
- package/dist/ai/genericInference.js.map +1 -1
- package/dist/ai/prompts.js +55 -49
- package/dist/ai/prompts.js.map +1 -1
- package/dist/approvals/approvalInbox.js +649 -133
- package/dist/approvals/approvalInbox.js.map +1 -1
- package/dist/approvals/index.d.ts +2 -2
- package/dist/approvals/index.js +1 -1
- package/dist/approvals/index.js.map +1 -1
- package/dist/behavior/behaviorStore.js +34 -14
- package/dist/behavior/behaviorStore.js.map +1 -1
- package/dist/cache/index.js +14 -5
- package/dist/cache/index.js.map +1 -1
- package/dist/cli/index.js +226 -93
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/openUrl.js +17 -3
- package/dist/cli/openUrl.js.map +1 -1
- package/dist/cli/parser.js +79 -18
- package/dist/cli/parser.js.map +1 -1
- package/dist/config/dataPaths.js +4 -2
- package/dist/config/dataPaths.js.map +1 -1
- package/dist/config/localConfig.js +5 -2
- package/dist/config/localConfig.js.map +1 -1
- package/dist/config/serverConfig.js +11 -5
- package/dist/config/serverConfig.js.map +1 -1
- package/dist/contracts/contract.js +311 -83
- package/dist/contracts/contract.js.map +1 -1
- package/dist/contracts/index.d.ts +2 -2
- package/dist/contracts/index.js +1 -1
- package/dist/contracts/index.js.map +1 -1
- package/dist/credentials/credentialBroker.js +391 -79
- package/dist/credentials/credentialBroker.js.map +1 -1
- package/dist/credentials/index.d.ts +2 -2
- package/dist/credentials/index.js +1 -1
- package/dist/credentials/index.js.map +1 -1
- package/dist/dashboard/app.js +823 -685
- package/dist/dashboard/dashboard.js.map +1 -1
- package/dist/dashboard/index.html +704 -267
- package/dist/dashboard/styles.css +1040 -349
- package/dist/egress/capabilities.js +55 -25
- package/dist/egress/capabilities.js.map +1 -1
- package/dist/egress/linuxBootstrap.js +47 -11
- package/dist/egress/linuxBootstrap.js.map +1 -1
- package/dist/egress/run.js +170 -31
- package/dist/egress/run.js.map +1 -1
- package/dist/errors/errorEngine.js +15 -3
- package/dist/errors/errorEngine.js.map +1 -1
- package/dist/errors/providerErrors.js.map +1 -1
- package/dist/evals/evals.js +304 -78
- package/dist/evals/evals.js.map +1 -1
- package/dist/evals/index.d.ts +2 -2
- package/dist/evals/index.js +1 -1
- package/dist/evals/index.js.map +1 -1
- package/dist/evidence/index.d.ts +2 -2
- package/dist/evidence/index.js +1 -1
- package/dist/evidence/index.js.map +1 -1
- package/dist/evidence/report.d.ts +1 -1
- package/dist/evidence/report.js +263 -79
- package/dist/evidence/report.js.map +1 -1
- package/dist/fault/faultLab.js +24 -9
- package/dist/fault/faultLab.js.map +1 -1
- package/dist/index.d.ts +42 -42
- package/dist/index.js +21 -21
- package/dist/index.js.map +1 -1
- package/dist/inventory/analysis.d.ts +1 -1
- package/dist/inventory/analysis.js +174 -50
- package/dist/inventory/analysis.js.map +1 -1
- package/dist/inventory/index.d.ts +4 -4
- package/dist/inventory/index.js +3 -3
- package/dist/inventory/index.js.map +1 -1
- package/dist/inventory/inventoryController.js +362 -115
- package/dist/inventory/inventoryController.js.map +1 -1
- package/dist/inventory/types.js +680 -140
- package/dist/inventory/types.js.map +1 -1
- package/dist/ledger/actionLedger.js +355 -78
- package/dist/ledger/actionLedger.js.map +1 -1
- package/dist/ledger/index.d.ts +2 -2
- package/dist/ledger/index.js +1 -1
- package/dist/ledger/index.js.map +1 -1
- package/dist/mcp/server.js +26 -11
- package/dist/mcp/server.js.map +1 -1
- package/dist/policy/index.d.ts +2 -2
- package/dist/policy/index.js +1 -1
- package/dist/policy/index.js.map +1 -1
- package/dist/policy/policy.js +112 -32
- package/dist/policy/policy.js.map +1 -1
- package/dist/productTelemetry/index.js +71 -20
- package/dist/productTelemetry/index.js.map +1 -1
- package/dist/providers/conformance.js +10 -5
- package/dist/providers/conformance.js.map +1 -1
- package/dist/providers/discord.js +2 -2
- package/dist/providers/generic.js +3 -3
- package/dist/providers/github.js +2 -2
- package/dist/providers/index.d.ts +3 -3
- package/dist/providers/index.js +2 -2
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/openai.js +3 -3
- package/dist/providers/packs/resendPack.js +51 -25
- package/dist/providers/packs/resendPack.js.map +1 -1
- package/dist/providers/packs/stripePack.js +590 -121
- package/dist/providers/packs/stripePack.js.map +1 -1
- package/dist/providers/registry.js +4 -4
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/runtime.js +7 -6
- package/dist/providers/runtime.js.map +1 -1
- package/dist/providers/stripeWebhook.js +39 -10
- package/dist/providers/stripeWebhook.js.map +1 -1
- package/dist/providers/twilio.js +2 -2
- package/dist/proxy/cacheKey.js +9 -2
- package/dist/proxy/cacheKey.js.map +1 -1
- package/dist/proxy/providerDetector.js +22 -9
- package/dist/proxy/providerDetector.js.map +1 -1
- package/dist/proxy/proxyHandler.js +57 -22
- package/dist/proxy/proxyHandler.js.map +1 -1
- package/dist/proxy/requestNormalizer.js +4 -2
- package/dist/proxy/requestNormalizer.js.map +1 -1
- package/dist/reliability/costGovernance.js +211 -49
- package/dist/reliability/costGovernance.js.map +1 -1
- package/dist/reliability/index.d.ts +8 -8
- package/dist/reliability/index.js +4 -4
- package/dist/reliability/index.js.map +1 -1
- package/dist/reliability/reconciliation.js +274 -65
- package/dist/reliability/reconciliation.js.map +1 -1
- package/dist/reliability/runtimeHealth.js +127 -35
- package/dist/reliability/runtimeHealth.js.map +1 -1
- package/dist/reliability/slo.js +257 -50
- package/dist/reliability/slo.js.map +1 -1
- package/dist/report/safetyReport.d.ts +6 -0
- package/dist/report/safetyReport.js +56 -12
- package/dist/report/safetyReport.js.map +1 -1
- package/dist/rules/aiRules.js +5 -5
- package/dist/rules/aiRules.js.map +1 -1
- package/dist/safety/index.d.ts +2 -2
- package/dist/safety/index.js +1 -1
- package/dist/safety/index.js.map +1 -1
- package/dist/safety/safetyController.js +675 -165
- package/dist/safety/safetyController.js.map +1 -1
- package/dist/scenarios/scenarioBundle.js +204 -71
- package/dist/scenarios/scenarioBundle.js.map +1 -1
- package/dist/scenarios/scenarioStore.js +88 -29
- package/dist/scenarios/scenarioStore.js.map +1 -1
- package/dist/security/headerSanitizer.js +52 -10
- package/dist/security/headerSanitizer.js.map +1 -1
- package/dist/security/secrets.js +10 -5
- package/dist/security/secrets.js.map +1 -1
- package/dist/server/accessControl.js +37 -10
- package/dist/server/accessControl.js.map +1 -1
- package/dist/server/createServer.js +1 -1
- package/dist/server/createServer.js.map +1 -1
- package/dist/server/eventsStore.js +15 -5
- package/dist/server/eventsStore.js.map +1 -1
- package/dist/server/routes.js +61 -19
- package/dist/server/routes.js.map +1 -1
- package/dist/server/sse.js +1 -1
- package/dist/server/sse.js.map +1 -1
- package/dist/setup/sdkDetector.js +7 -2
- package/dist/setup/sdkDetector.js.map +1 -1
- package/dist/setup/setupGenerator.js +136 -48
- package/dist/setup/setupGenerator.js.map +1 -1
- package/dist/state/stateExtractor.js +3 -1
- package/dist/state/stateExtractor.js.map +1 -1
- package/dist/state/stateResolver.js +10 -7
- package/dist/state/stateResolver.js.map +1 -1
- package/dist/state/stateStore.js +13 -4
- package/dist/state/stateStore.js.map +1 -1
- package/dist/storage/fileStore.js +12 -4
- package/dist/storage/fileStore.js.map +1 -1
- package/dist/teamControl/controlPlane.js +846 -174
- package/dist/teamControl/controlPlane.js.map +1 -1
- package/dist/teamControl/deployment.js +19 -5
- package/dist/teamControl/deployment.js.map +1 -1
- package/dist/teamControl/index.d.ts +4 -4
- package/dist/teamControl/index.js +2 -2
- package/dist/teamControl/index.js.map +1 -1
- package/dist/tests/testGenerator.js +15 -7
- package/dist/tests/testGenerator.js.map +1 -1
- package/dist/trust/index.d.ts +2 -2
- package/dist/trust/index.js +1 -1
- package/dist/trust/index.js.map +1 -1
- package/dist/trust/trustLadder.js +309 -63
- package/dist/trust/trustLadder.js.map +1 -1
- package/dist/worlds/index.d.ts +2 -2
- package/dist/worlds/index.js +1 -1
- package/dist/worlds/index.js.map +1 -1
- package/dist/worlds/worlds.js +364 -65
- package/dist/worlds/worlds.js.map +1 -1
- package/docs/assets/dashboard.png +0 -0
- package/docs/ci.md +49 -49
- package/docs/github-actions.md +84 -84
- package/docs/mcp.md +58 -52
- package/docs/policy.md +63 -61
- package/docs/providers/authoring-packs.md +180 -175
- package/docs/providers/stripe-core-pack.md +84 -82
- package/docs/releases/README.md +13 -0
- package/docs/security/action-gateway-threat-model.md +34 -34
- package/docs/security/action-ledger-incident-replay-threat-model.md +42 -42
- package/docs/security/approval-inbox-threat-model.md +35 -35
- package/docs/security/credential-broker-threat-model.md +52 -52
- package/docs/security/egress-threat-model.md +126 -126
- package/docs/security/inventory-threat-model.md +104 -104
- package/docs/security/kill-switch-budgets-threat-model.md +37 -37
- package/docs/security/reliability-threat-model.md +111 -111
- package/docs/security/trust-ladder-threat-model.md +33 -33
- package/docs/telemetry.md +14 -0
- package/docs/usage.md +680 -571
- package/examples/README.md +10 -10
- package/examples/agent-instructions/README.md +39 -39
- package/examples/ci-smoke/README.md +21 -21
- package/examples/ci-smoke/ghostapi.policy.yaml +22 -22
- package/examples/ci-smoke/package.json +9 -9
- package/examples/ci-smoke/production-egress.mjs +4 -2
- package/examples/ci-smoke/safe.mjs +21 -17
- package/examples/evals/README.md +24 -24
- package/examples/evals/retry-after.eval.json +71 -52
- package/examples/generic-rest/README.md +27 -27
- package/examples/github-fetch/README.md +38 -35
- package/examples/openai-streaming/README.md +16 -16
- package/examples/openai-streaming/streaming-tool-call.mjs +44 -31
- package/examples/policy/ghostapi.policy.yaml +27 -27
- package/examples/record-replay/README.md +19 -19
- package/examples/record-replay/replay-requests.json +22 -22
- package/examples/record-replay/stripe-checkout.har.json +30 -30
- package/examples/resend-node/README.md +22 -22
- package/examples/stripe-node/README.md +35 -35
- package/examples/stripe-node/checkout-flow.mjs +87 -61
- package/examples/twilio-node/README.md +21 -21
- package/examples/worlds/README.md +19 -19
- package/examples/worlds/subscription-recovery.mjs +21 -12
- package/package.json +28 -6
- package/dist/landing/assets/index-BXR41fAD.css +0 -1
- package/dist/landing/assets/index-CxWsopzP.css +0 -1
- package/dist/landing/assets/index-D8_ggMRE.js +0 -55
- package/dist/landing/assets/index-DZlpyhqd.js +0 -55
- package/dist/landing/assets/index-Dk-Xezs3.js +0 -55
- package/dist/landing/index.html +0 -15
- package/dist/landing/landing.d.ts +0 -3
- package/dist/landing/landing.js +0 -12
- package/dist/landing/landing.js.map +0 -1
- package/docs/commercial/README.md +0 -29
- package/docs/commercial/data-inventory.md +0 -36
- package/docs/commercial/manual-invoicing.md +0 -44
- package/docs/commercial/metrics.md +0 -51
- package/docs/commercial/pricing.md +0 -103
- package/docs/commercial/privacy-policy-draft.md +0 -41
- package/docs/commercial/terms-topics-for-counsel.md +0 -51
- package/docs/design-partners/README.md +0 -52
- package/docs/design-partners/demo-narratives.md +0 -33
- package/docs/design-partners/design-partner-offer.md +0 -39
- package/docs/design-partners/discovery-questions.md +0 -28
- package/docs/design-partners/feedback-capture-template.md +0 -48
- package/docs/design-partners/icp-one-pager.md +0 -43
- package/docs/design-partners/onboarding-checklist.md +0 -33
- package/docs/design-partners/pilot-success-criteria.md +0 -27
- package/docs/design-partners/pricing-interview-script.md +0 -18
- package/docs/design-partners/telemetry-plan.md +0 -48
- package/docs/development/baseline.md +0 -114
- package/docs/development/onboarding-smoke.md +0 -37
- package/docs/development/verification-0.1.8.md +0 -25
- package/docs/enterprise-product-roadmap-ru.md +0 -1879
- package/docs/fundraising/README.md +0 -87
- package/docs/fundraising/data-room-checklist.md +0 -72
- package/docs/fundraising/demo-script.md +0 -52
- package/docs/fundraising/design-partners-50.md +0 -74
- package/docs/fundraising/launch-posts.md +0 -37
- package/docs/fundraising/metrics-and-evidence.md +0 -30
- package/docs/fundraising/roadmap-12-month.md +0 -25
- package/docs/fundraising/technical-due-diligence-index.md +0 -37
- package/docs/fundraising/yc-application.md +0 -25
- package/docs/hosted-pilot.md +0 -111
- package/docs/operations/disaster-recovery-runbook.md +0 -81
- package/docs/operations/kill-switch-runbook.md +0 -32
- package/docs/release-checklist.md +0 -83
- package/docs/release-migration-and-rollback.md +0 -63
- package/docs/release-readiness.md +0 -39
- package/docs/team-control-plane.md +0 -72
|
@@ -3,7 +3,7 @@ import { lstat, readFile } from "node:fs/promises";
|
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { getDataPaths } from "../config/dataPaths.js";
|
|
5
5
|
import { sanitizeSecretString } from "../security/secrets.js";
|
|
6
|
-
import { atomicWriteJson, ensurePrivateDirectory, withFileLock } from "../storage/fileStore.js";
|
|
6
|
+
import { atomicWriteJson, ensurePrivateDirectory, withFileLock, } from "../storage/fileStore.js";
|
|
7
7
|
const SCHEMA_VERSION = 1;
|
|
8
8
|
const MAX_STORE_BYTES = 1024 * 1024;
|
|
9
9
|
const MAX_CONTROLS = 1_000;
|
|
@@ -15,7 +15,17 @@ const DEFAULT_LEASE_TTL_MS = 60_000;
|
|
|
15
15
|
const KILL_SWITCH_ERROR_CODE = "SAFETY_KILL_SWITCH";
|
|
16
16
|
const IDENTIFIER = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
|
17
17
|
const HASH = /^[a-f0-9]{64}$/;
|
|
18
|
-
export const SAFETY_SCOPE_KINDS = [
|
|
18
|
+
export const SAFETY_SCOPE_KINDS = [
|
|
19
|
+
"global",
|
|
20
|
+
"organization",
|
|
21
|
+
"project",
|
|
22
|
+
"environment",
|
|
23
|
+
"agent",
|
|
24
|
+
"workload",
|
|
25
|
+
"provider",
|
|
26
|
+
"operation",
|
|
27
|
+
"risk_class",
|
|
28
|
+
];
|
|
19
29
|
export class SafetyControllerError extends Error {
|
|
20
30
|
code;
|
|
21
31
|
constructor(message, code) {
|
|
@@ -36,7 +46,8 @@ export class LocalSafetyController {
|
|
|
36
46
|
constructor(options = {}) {
|
|
37
47
|
this.path = options.path ?? getDataPaths().safetyController;
|
|
38
48
|
this.now = options.now ?? (() => new Date());
|
|
39
|
-
this.emergencyAuthorizer =
|
|
49
|
+
this.emergencyAuthorizer =
|
|
50
|
+
options.emergencyAuthorizer ?? createDisabledSafetyEmergencyAuthorizer();
|
|
40
51
|
this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
41
52
|
}
|
|
42
53
|
async configureBudget(input) {
|
|
@@ -98,12 +109,12 @@ export class LocalSafetyController {
|
|
|
98
109
|
}
|
|
99
110
|
async admit(actionValue) {
|
|
100
111
|
const action = validateAction(actionValue);
|
|
101
|
-
const now = this.timestamp();
|
|
102
112
|
const key = ledgerKey(action);
|
|
103
113
|
const replay = await this.mutate((state, current) => {
|
|
104
114
|
pruneExpiredLeases(state, current, this.leaseTtlMs);
|
|
105
115
|
const sameIdempotency = state.ledger.find((candidate) => candidate.action.idempotencyKey === action.idempotencyKey);
|
|
106
|
-
if (sameIdempotency !== undefined &&
|
|
116
|
+
if (sameIdempotency !== undefined &&
|
|
117
|
+
sameIdempotency.action.actionHash !== action.actionHash)
|
|
107
118
|
throw new SafetyControllerError("Safety idempotency key collides with a different action hash.");
|
|
108
119
|
const existing = state.ledger.find((candidate) => candidate.key === key);
|
|
109
120
|
if (existing !== undefined)
|
|
@@ -112,7 +123,12 @@ export class LocalSafetyController {
|
|
|
112
123
|
if (state.ledger.length >= MAX_LEDGER)
|
|
113
124
|
throw new SafetyControllerError("Safety ledger limit was reached.");
|
|
114
125
|
assertBudgets(state, action, current);
|
|
115
|
-
state.ledger.push({
|
|
126
|
+
state.ledger.push({
|
|
127
|
+
key,
|
|
128
|
+
action,
|
|
129
|
+
status: "reserved",
|
|
130
|
+
admittedAt: current,
|
|
131
|
+
});
|
|
116
132
|
appendAudit(state, "action.admitted", key, "budget reservation created", current);
|
|
117
133
|
return false;
|
|
118
134
|
});
|
|
@@ -168,11 +184,14 @@ export class LocalSafetyController {
|
|
|
168
184
|
entry.completedAt = current;
|
|
169
185
|
entry.outcome = outcome;
|
|
170
186
|
}
|
|
171
|
-
appendAudit(state, outcome.success ? "action.completed" : "action.unknown", key, outcome.reason ??
|
|
187
|
+
appendAudit(state, outcome.success ? "action.completed" : "action.unknown", key, outcome.reason ??
|
|
188
|
+
(outcome.success
|
|
189
|
+
? "action completed"
|
|
190
|
+
: "action outcome is unknown"), current);
|
|
172
191
|
if (entry !== undefined)
|
|
173
192
|
evaluateCircuits(state, entry.action, current);
|
|
174
193
|
});
|
|
175
|
-
}
|
|
194
|
+
},
|
|
176
195
|
};
|
|
177
196
|
}
|
|
178
197
|
async enqueue(actionValue) {
|
|
@@ -184,7 +203,11 @@ export class LocalSafetyController {
|
|
|
184
203
|
return clone(existing);
|
|
185
204
|
if (state.queue.length >= MAX_QUEUE)
|
|
186
205
|
throw new SafetyControllerError("Safety queue is full; backpressure is active.");
|
|
187
|
-
const item = {
|
|
206
|
+
const item = {
|
|
207
|
+
id: `queue-${sha256(`${ledgerKey(action)}:${now}`).slice(0, 32)}`,
|
|
208
|
+
action,
|
|
209
|
+
queuedAt: now,
|
|
210
|
+
};
|
|
188
211
|
state.queue.push(item);
|
|
189
212
|
appendAudit(state, "queue.enqueued", item.id, "action is queued without execution", now);
|
|
190
213
|
return clone(item);
|
|
@@ -199,7 +222,9 @@ export class LocalSafetyController {
|
|
|
199
222
|
assertActionAllowed(state, item.action, now);
|
|
200
223
|
}
|
|
201
224
|
catch (error) {
|
|
202
|
-
const reason = error instanceof Error
|
|
225
|
+
const reason = error instanceof Error
|
|
226
|
+
? error.message
|
|
227
|
+
: "Safety queue action is denied.";
|
|
203
228
|
addDeadLetter(state, item, reason, now);
|
|
204
229
|
appendAudit(state, "queue.dead_lettered", item.id, reason, now);
|
|
205
230
|
return null;
|
|
@@ -215,19 +240,34 @@ export class LocalSafetyController {
|
|
|
215
240
|
const before = await this.read();
|
|
216
241
|
if (before.switches.some((control) => sameScope(control.scope, globalScope) && control.enabled))
|
|
217
242
|
throw new SafetyControllerError("Game-day test requires an inactive global kill switch.");
|
|
218
|
-
await this.stop({
|
|
243
|
+
await this.stop({
|
|
244
|
+
identity: input.stopIdentity,
|
|
245
|
+
scope: globalScope,
|
|
246
|
+
reason: `${reason} stop drill`,
|
|
247
|
+
});
|
|
219
248
|
const stoppedAt = this.timestamp();
|
|
220
|
-
await this.admit(action).then(() => {
|
|
221
|
-
|
|
249
|
+
await this.admit(action).then(() => {
|
|
250
|
+
throw new SafetyControllerError("Game-day kill switch did not block the action.");
|
|
251
|
+
}, (error) => {
|
|
252
|
+
if (!(error instanceof SafetyControllerError) ||
|
|
253
|
+
error.code !== KILL_SWITCH_ERROR_CODE)
|
|
222
254
|
throw error;
|
|
223
255
|
});
|
|
224
|
-
await this.reenable({
|
|
256
|
+
await this.reenable({
|
|
257
|
+
identity: input.reenableIdentity,
|
|
258
|
+
scope: globalScope,
|
|
259
|
+
reason: `${reason} recovery drill`,
|
|
260
|
+
});
|
|
225
261
|
const reenabledAt = this.timestamp();
|
|
226
262
|
await this.mutate((state, now) => appendAudit(state, "game_day.passed", ledgerKey(action), reason, now));
|
|
227
263
|
return { passed: true, stoppedAt, reenabledAt };
|
|
228
264
|
}
|
|
229
|
-
async inspect() {
|
|
230
|
-
|
|
265
|
+
async inspect() {
|
|
266
|
+
return this.read();
|
|
267
|
+
}
|
|
268
|
+
async readStateForTesting() {
|
|
269
|
+
return this.read();
|
|
270
|
+
}
|
|
231
271
|
async authorize(identity, permission) {
|
|
232
272
|
const operator = validateOperator(await this.emergencyAuthorizer.authenticate(identity));
|
|
233
273
|
if (!operator.permissions.includes(permission))
|
|
@@ -268,24 +308,62 @@ export class LocalSafetyController {
|
|
|
268
308
|
return value.toISOString();
|
|
269
309
|
}
|
|
270
310
|
leaseExpired(entry, now) {
|
|
271
|
-
return entry.status === "reserved" &&
|
|
311
|
+
return (entry.status === "reserved" &&
|
|
312
|
+
Date.parse(now) - Date.parse(entry.admittedAt) > this.leaseTtlMs);
|
|
272
313
|
}
|
|
273
314
|
}
|
|
274
315
|
export function createDisabledSafetyEmergencyAuthorizer() {
|
|
275
|
-
return {
|
|
316
|
+
return {
|
|
317
|
+
async authenticate() {
|
|
318
|
+
throw new SafetyControllerError("Emergency authorization is not configured.");
|
|
319
|
+
},
|
|
320
|
+
};
|
|
276
321
|
}
|
|
277
322
|
export function createTestSafetyEmergencyAuthorizer() {
|
|
278
323
|
const issued = new WeakSet();
|
|
279
324
|
return {
|
|
280
|
-
authorizer: {
|
|
281
|
-
|
|
282
|
-
|
|
325
|
+
authorizer: {
|
|
326
|
+
async authenticate(identity) {
|
|
327
|
+
if (identity === null ||
|
|
328
|
+
typeof identity !== "object" ||
|
|
329
|
+
!issued.has(identity))
|
|
330
|
+
throw new SafetyControllerError("Emergency operator identity is not authenticated.");
|
|
331
|
+
return validateOperator(identity);
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
issue(input) {
|
|
335
|
+
const operator = Object.freeze(validateOperator(input));
|
|
336
|
+
issued.add(operator);
|
|
337
|
+
return operator;
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function emptyState() {
|
|
342
|
+
return {
|
|
343
|
+
schemaVersion: 1,
|
|
344
|
+
switches: [],
|
|
345
|
+
budgets: [],
|
|
346
|
+
circuits: [],
|
|
347
|
+
ledger: [],
|
|
348
|
+
queue: [],
|
|
349
|
+
deadLetters: [],
|
|
350
|
+
auditAnchor: sha256("ghostapi.safety.audit.v1"),
|
|
351
|
+
audit: [],
|
|
283
352
|
};
|
|
284
353
|
}
|
|
285
|
-
function emptyState() { return { schemaVersion: 1, switches: [], budgets: [], circuits: [], ledger: [], queue: [], deadLetters: [], auditAnchor: sha256("ghostapi.safety.audit.v1"), audit: [] }; }
|
|
286
354
|
function validateState(value) {
|
|
287
355
|
const state = object(value, "Safety controller store must be an object.");
|
|
288
|
-
exactKeys(state, [
|
|
356
|
+
exactKeys(state, [
|
|
357
|
+
"schemaVersion",
|
|
358
|
+
"switches",
|
|
359
|
+
"budgets",
|
|
360
|
+
"circuits",
|
|
361
|
+
"ledger",
|
|
362
|
+
"queue",
|
|
363
|
+
"deadLetters",
|
|
364
|
+
"auditAnchor",
|
|
365
|
+
"audit",
|
|
366
|
+
], "Safety controller store");
|
|
289
367
|
if (state.schemaVersion !== SCHEMA_VERSION)
|
|
290
368
|
throw new SafetyControllerError("Unsupported safety controller schema version.");
|
|
291
369
|
const switches = array(state.switches, "Safety switches", MAX_CONTROLS).map(validateSwitch);
|
|
@@ -304,29 +382,105 @@ function validateState(value) {
|
|
|
304
382
|
const auditAnchor = hash(state.auditAnchor, "Safety audit anchor");
|
|
305
383
|
const audit = array(state.audit, "Safety audit", MAX_AUDIT).map(validateAudit);
|
|
306
384
|
validateAuditChain(auditAnchor, audit);
|
|
307
|
-
return {
|
|
385
|
+
return {
|
|
386
|
+
schemaVersion: 1,
|
|
387
|
+
switches,
|
|
388
|
+
budgets,
|
|
389
|
+
circuits,
|
|
390
|
+
ledger,
|
|
391
|
+
queue,
|
|
392
|
+
deadLetters,
|
|
393
|
+
auditAnchor,
|
|
394
|
+
audit,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function validateSwitch(value) {
|
|
398
|
+
const control = object(value, "Safety switch is invalid.");
|
|
399
|
+
exactKeys(control, ["scope", "enabled", "changedAt", "reason", "changedBy"], "Safety switch");
|
|
400
|
+
if (typeof control.enabled !== "boolean")
|
|
401
|
+
throw new SafetyControllerError("Safety switch enabled flag is invalid.");
|
|
402
|
+
return {
|
|
403
|
+
scope: validateScope(control.scope),
|
|
404
|
+
enabled: control.enabled,
|
|
405
|
+
changedAt: timestamp(control.changedAt, "Safety switch time"),
|
|
406
|
+
reason: text(control.reason, "Safety switch reason", 200),
|
|
407
|
+
changedBy: identifier(control.changedBy, "Safety switch operator"),
|
|
408
|
+
};
|
|
308
409
|
}
|
|
309
|
-
function validateSwitch(value) { const control = object(value, "Safety switch is invalid."); exactKeys(control, ["scope", "enabled", "changedAt", "reason", "changedBy"], "Safety switch"); if (typeof control.enabled !== "boolean")
|
|
310
|
-
throw new SafetyControllerError("Safety switch enabled flag is invalid."); return { scope: validateScope(control.scope), enabled: control.enabled, changedAt: timestamp(control.changedAt, "Safety switch time"), reason: text(control.reason, "Safety switch reason", 200), changedBy: identifier(control.changedBy, "Safety switch operator") }; }
|
|
311
410
|
function validateBudget(value) {
|
|
312
411
|
const budget = object(value, "Safety budget is invalid.");
|
|
313
412
|
exactKeys(budget, ["id", "scope", "windowMs", "velocityWindowMs", "limits"], "Safety budget");
|
|
314
413
|
const limits = object(budget.limits, "Safety budget limits are invalid.");
|
|
315
|
-
exactKeys(limits, [
|
|
414
|
+
exactKeys(limits, [
|
|
415
|
+
"monetaryAmountMinor",
|
|
416
|
+
"requests",
|
|
417
|
+
"messages",
|
|
418
|
+
"mutations",
|
|
419
|
+
"deletes",
|
|
420
|
+
"tokenCost",
|
|
421
|
+
"concurrency",
|
|
422
|
+
"velocity",
|
|
423
|
+
], "Safety budget limits", [
|
|
424
|
+
"monetaryAmountMinor",
|
|
425
|
+
"requests",
|
|
426
|
+
"messages",
|
|
427
|
+
"mutations",
|
|
428
|
+
"deletes",
|
|
429
|
+
"tokenCost",
|
|
430
|
+
"concurrency",
|
|
431
|
+
"velocity",
|
|
432
|
+
]);
|
|
316
433
|
const parsed = {};
|
|
317
|
-
for (const key of [
|
|
434
|
+
for (const key of [
|
|
435
|
+
"monetaryAmountMinor",
|
|
436
|
+
"requests",
|
|
437
|
+
"messages",
|
|
438
|
+
"mutations",
|
|
439
|
+
"deletes",
|
|
440
|
+
"tokenCost",
|
|
441
|
+
"concurrency",
|
|
442
|
+
"velocity",
|
|
443
|
+
])
|
|
318
444
|
if (limits[key] !== undefined)
|
|
319
445
|
parsed[key] = nonNegative(limits[key], `Safety budget ${key}`, Number.MAX_SAFE_INTEGER);
|
|
320
446
|
if (Object.keys(parsed).length === 0)
|
|
321
447
|
throw new SafetyControllerError("Safety budget requires at least one limit.");
|
|
322
|
-
return {
|
|
448
|
+
return {
|
|
449
|
+
id: identifier(budget.id, "Safety budget id"),
|
|
450
|
+
scope: validateScope(budget.scope),
|
|
451
|
+
windowMs: boundedMs(budget.windowMs, "Safety budget window"),
|
|
452
|
+
velocityWindowMs: boundedMs(budget.velocityWindowMs, "Safety velocity window"),
|
|
453
|
+
limits: parsed,
|
|
454
|
+
};
|
|
323
455
|
}
|
|
324
456
|
function validateCircuit(value) {
|
|
325
457
|
const circuit = object(value, "Safety circuit is invalid.");
|
|
326
|
-
exactKeys(circuit, [
|
|
458
|
+
exactKeys(circuit, [
|
|
459
|
+
"id",
|
|
460
|
+
"scope",
|
|
461
|
+
"windowMs",
|
|
462
|
+
"minimumSamples",
|
|
463
|
+
"maxFailureRateBps",
|
|
464
|
+
"maxPolicyViolations",
|
|
465
|
+
"maxLatencyMs",
|
|
466
|
+
"maxReconciliationMismatches",
|
|
467
|
+
"state",
|
|
468
|
+
"openedAt",
|
|
469
|
+
"reason",
|
|
470
|
+
], "Safety circuit", ["openedAt", "reason"]);
|
|
327
471
|
if (circuit.state !== "closed" && circuit.state !== "open")
|
|
328
472
|
throw new SafetyControllerError("Safety circuit state is invalid.");
|
|
329
|
-
const result = {
|
|
473
|
+
const result = {
|
|
474
|
+
id: identifier(circuit.id, "Safety circuit id"),
|
|
475
|
+
scope: validateScope(circuit.scope),
|
|
476
|
+
windowMs: boundedMs(circuit.windowMs, "Safety circuit window"),
|
|
477
|
+
minimumSamples: positive(circuit.minimumSamples, "Safety circuit minimum samples", MAX_LEDGER),
|
|
478
|
+
maxFailureRateBps: bps(circuit.maxFailureRateBps, "Safety circuit failure rate"),
|
|
479
|
+
maxPolicyViolations: nonNegative(circuit.maxPolicyViolations, "Safety circuit policy violations", MAX_LEDGER),
|
|
480
|
+
maxLatencyMs: nonNegative(circuit.maxLatencyMs, "Safety circuit latency", 24 * 60 * 60 * 1000),
|
|
481
|
+
maxReconciliationMismatches: nonNegative(circuit.maxReconciliationMismatches, "Safety circuit reconciliation mismatches", MAX_LEDGER),
|
|
482
|
+
state: circuit.state,
|
|
483
|
+
};
|
|
330
484
|
if (result.state === "open") {
|
|
331
485
|
if (circuit.openedAt === undefined || circuit.reason === undefined)
|
|
332
486
|
throw new SafetyControllerError("Open safety circuit requires audit metadata.");
|
|
@@ -337,141 +491,497 @@ function validateCircuit(value) {
|
|
|
337
491
|
}
|
|
338
492
|
function validateAction(value) {
|
|
339
493
|
const action = object(value, "Safety action is invalid.");
|
|
340
|
-
exactKeys(action, [
|
|
341
|
-
|
|
494
|
+
exactKeys(action, [
|
|
495
|
+
"organizationId",
|
|
496
|
+
"projectId",
|
|
497
|
+
"environment",
|
|
498
|
+
"actorId",
|
|
499
|
+
"workloadId",
|
|
500
|
+
"provider",
|
|
501
|
+
"operation",
|
|
502
|
+
"riskClass",
|
|
503
|
+
"idempotencyKey",
|
|
504
|
+
"actionHash",
|
|
505
|
+
"costs",
|
|
506
|
+
], "Safety action");
|
|
507
|
+
if (action.riskClass !== "read" &&
|
|
508
|
+
action.riskClass !== "write" &&
|
|
509
|
+
action.riskClass !== "money_movement" &&
|
|
510
|
+
action.riskClass !== "external_communication" &&
|
|
511
|
+
action.riskClass !== "delete" &&
|
|
512
|
+
action.riskClass !== "deploy")
|
|
342
513
|
throw new SafetyControllerError("Safety action risk class is invalid.");
|
|
343
514
|
const costs = object(action.costs, "Safety action costs are invalid.");
|
|
344
|
-
exactKeys(costs, [
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
function
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
function
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
515
|
+
exactKeys(costs, [
|
|
516
|
+
"monetaryAmountMinor",
|
|
517
|
+
"requests",
|
|
518
|
+
"messages",
|
|
519
|
+
"mutations",
|
|
520
|
+
"deletes",
|
|
521
|
+
"tokenCost",
|
|
522
|
+
], "Safety action costs");
|
|
523
|
+
return {
|
|
524
|
+
organizationId: identifier(action.organizationId, "Safety organization id"),
|
|
525
|
+
projectId: identifier(action.projectId, "Safety project id"),
|
|
526
|
+
environment: identifier(action.environment, "Safety environment"),
|
|
527
|
+
actorId: identifier(action.actorId, "Safety actor id"),
|
|
528
|
+
workloadId: identifier(action.workloadId, "Safety workload id"),
|
|
529
|
+
provider: identifier(action.provider, "Safety provider"),
|
|
530
|
+
operation: identifier(action.operation, "Safety operation"),
|
|
531
|
+
riskClass: action.riskClass,
|
|
532
|
+
idempotencyKey: identifier(action.idempotencyKey, "Safety idempotency key"),
|
|
533
|
+
actionHash: hash(action.actionHash, "Safety action hash"),
|
|
534
|
+
costs: {
|
|
535
|
+
monetaryAmountMinor: nonNegative(costs.monetaryAmountMinor, "Safety monetary amount", Number.MAX_SAFE_INTEGER),
|
|
536
|
+
requests: nonNegative(costs.requests, "Safety request count", Number.MAX_SAFE_INTEGER),
|
|
537
|
+
messages: nonNegative(costs.messages, "Safety message count", Number.MAX_SAFE_INTEGER),
|
|
538
|
+
mutations: nonNegative(costs.mutations, "Safety mutation count", Number.MAX_SAFE_INTEGER),
|
|
539
|
+
deletes: nonNegative(costs.deletes, "Safety delete count", Number.MAX_SAFE_INTEGER),
|
|
540
|
+
tokenCost: nonNegative(costs.tokenCost, "Safety token cost", Number.MAX_SAFE_INTEGER),
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
function validateScope(value) {
|
|
545
|
+
const scope = object(value, "Safety scope is invalid.");
|
|
546
|
+
exactKeys(scope, ["kind", "id"], "Safety scope", ["id"]);
|
|
547
|
+
if (!SAFETY_SCOPE_KINDS.includes(scope.kind))
|
|
548
|
+
throw new SafetyControllerError("Safety scope kind is invalid.");
|
|
549
|
+
if (scope.kind === "global") {
|
|
550
|
+
if (scope.id !== undefined)
|
|
551
|
+
throw new SafetyControllerError("Global safety scope cannot have an id.");
|
|
552
|
+
return { kind: "global" };
|
|
553
|
+
}
|
|
554
|
+
if (scope.id === undefined)
|
|
555
|
+
throw new SafetyControllerError("Scoped safety control requires an id.");
|
|
556
|
+
return {
|
|
557
|
+
kind: scope.kind,
|
|
558
|
+
id: identifier(scope.id, "Safety scope id"),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function validateOperator(value) {
|
|
562
|
+
const operator = object(value, "Emergency operator is invalid.");
|
|
563
|
+
exactKeys(operator, ["id", "principalId", "permissions"], "Emergency operator");
|
|
564
|
+
const permissions = array(operator.permissions, "Emergency operator permissions", 8).map((permission) => {
|
|
565
|
+
if (permission !== "safety.stop" &&
|
|
566
|
+
permission !== "safety.reenable" &&
|
|
567
|
+
permission !== "safety.configure" &&
|
|
568
|
+
permission !== "safety.inspect")
|
|
569
|
+
throw new SafetyControllerError("Emergency operator permission is invalid.");
|
|
570
|
+
return permission;
|
|
571
|
+
});
|
|
572
|
+
unique(permissions, "Emergency operator permissions");
|
|
573
|
+
return {
|
|
574
|
+
id: identifier(operator.id, "Emergency operator id"),
|
|
575
|
+
principalId: identifier(operator.principalId, "Emergency operator principal id"),
|
|
576
|
+
permissions,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function validateLedger(value) {
|
|
580
|
+
const entry = object(value, "Safety ledger entry is invalid.");
|
|
581
|
+
exactKeys(entry, ["key", "action", "status", "admittedAt", "completedAt", "outcome"], "Safety ledger entry", ["completedAt", "outcome"]);
|
|
582
|
+
if (entry.status !== "reserved" && entry.status !== "completed")
|
|
583
|
+
throw new SafetyControllerError("Safety ledger status is invalid.");
|
|
584
|
+
const action = validateAction(entry.action);
|
|
585
|
+
const completedAt = entry.completedAt === undefined
|
|
586
|
+
? undefined
|
|
587
|
+
: timestamp(entry.completedAt, "Safety completion time");
|
|
588
|
+
const outcome = entry.outcome === undefined ? undefined : validateOutcome(entry.outcome);
|
|
589
|
+
if (entry.status === "completed" &&
|
|
590
|
+
(completedAt === undefined || outcome === undefined))
|
|
591
|
+
throw new SafetyControllerError("Completed safety ledger entry is invalid.");
|
|
592
|
+
if (entry.status === "reserved" &&
|
|
593
|
+
(completedAt !== undefined || outcome !== undefined))
|
|
594
|
+
throw new SafetyControllerError("Reserved safety ledger entry is invalid.");
|
|
595
|
+
const key = text(entry.key, "Safety ledger key", 300);
|
|
596
|
+
if (key !== ledgerKey(action))
|
|
597
|
+
throw new SafetyControllerError("Safety ledger key does not match its action.");
|
|
598
|
+
return {
|
|
599
|
+
key,
|
|
600
|
+
action,
|
|
601
|
+
status: entry.status,
|
|
602
|
+
admittedAt: timestamp(entry.admittedAt, "Safety admission time"),
|
|
603
|
+
...(completedAt === undefined ? {} : { completedAt }),
|
|
604
|
+
...(outcome === undefined ? {} : { outcome }),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function validateOutcome(value) {
|
|
608
|
+
const outcome = object(value, "Safety action outcome is invalid.");
|
|
609
|
+
exactKeys(outcome, [
|
|
610
|
+
"success",
|
|
611
|
+
"policyViolation",
|
|
612
|
+
"reconciliationMismatch",
|
|
613
|
+
"latencyMs",
|
|
614
|
+
"reason",
|
|
615
|
+
], "Safety action outcome", ["policyViolation", "reconciliationMismatch", "reason"]);
|
|
616
|
+
if (typeof outcome.success !== "boolean" ||
|
|
617
|
+
(outcome.policyViolation !== undefined &&
|
|
618
|
+
typeof outcome.policyViolation !== "boolean") ||
|
|
619
|
+
(outcome.reconciliationMismatch !== undefined &&
|
|
620
|
+
typeof outcome.reconciliationMismatch !== "boolean"))
|
|
621
|
+
throw new SafetyControllerError("Safety action outcome flags are invalid.");
|
|
622
|
+
return {
|
|
623
|
+
success: outcome.success,
|
|
624
|
+
policyViolation: outcome.policyViolation ?? false,
|
|
625
|
+
reconciliationMismatch: outcome.reconciliationMismatch ?? false,
|
|
626
|
+
latencyMs: nonNegative(outcome.latencyMs, "Safety action latency", 24 * 60 * 60 * 1000),
|
|
627
|
+
...(outcome.reason === undefined
|
|
628
|
+
? {}
|
|
629
|
+
: { reason: text(outcome.reason, "Safety action outcome reason", 200) }),
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
function validateQueueItem(value) {
|
|
633
|
+
const item = object(value, "Safety queue item is invalid.");
|
|
634
|
+
exactKeys(item, ["id", "action", "queuedAt"], "Safety queue item");
|
|
635
|
+
return {
|
|
636
|
+
id: identifier(item.id, "Safety queue id"),
|
|
637
|
+
action: validateAction(item.action),
|
|
638
|
+
queuedAt: timestamp(item.queuedAt, "Safety queue time"),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function validateDeadLetter(value) {
|
|
642
|
+
const item = object(value, "Safety dead-letter item is invalid.");
|
|
643
|
+
exactKeys(item, ["id", "action", "queuedAt", "reason", "deadLetteredAt"], "Safety dead-letter item");
|
|
644
|
+
return {
|
|
645
|
+
id: identifier(item.id, "Safety queue id"),
|
|
646
|
+
action: validateAction(item.action),
|
|
647
|
+
queuedAt: timestamp(item.queuedAt, "Safety queue time"),
|
|
648
|
+
reason: text(item.reason, "Safety dead-letter reason", 200),
|
|
649
|
+
deadLetteredAt: timestamp(item.deadLetteredAt, "Safety dead-letter time"),
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function validateAudit(value) {
|
|
653
|
+
const record = object(value, "Safety audit record is invalid.");
|
|
654
|
+
exactKeys(record, [
|
|
655
|
+
"sequence",
|
|
656
|
+
"event",
|
|
657
|
+
"subject",
|
|
658
|
+
"reason",
|
|
659
|
+
"actorPrincipalId",
|
|
660
|
+
"timestamp",
|
|
661
|
+
"previousHash",
|
|
662
|
+
"recordHash",
|
|
663
|
+
], "Safety audit record", ["actorPrincipalId"]);
|
|
664
|
+
const base = {
|
|
665
|
+
sequence: positive(record.sequence, "Safety audit sequence", MAX_AUDIT),
|
|
666
|
+
event: text(record.event, "Safety audit event", 80),
|
|
667
|
+
subject: text(record.subject, "Safety audit subject", 300),
|
|
668
|
+
reason: text(record.reason, "Safety audit reason", 200),
|
|
669
|
+
...(record.actorPrincipalId === undefined
|
|
670
|
+
? {}
|
|
671
|
+
: {
|
|
672
|
+
actorPrincipalId: identifier(record.actorPrincipalId, "Safety audit actor"),
|
|
673
|
+
}),
|
|
674
|
+
timestamp: timestamp(record.timestamp, "Safety audit time"),
|
|
675
|
+
previousHash: hash(record.previousHash, "Safety audit previous hash"),
|
|
676
|
+
};
|
|
677
|
+
if (hash(record.recordHash, "Safety audit record hash") !==
|
|
678
|
+
sha256(canonical(base)))
|
|
679
|
+
throw new SafetyControllerError("Safety audit record hash is invalid.");
|
|
680
|
+
return { ...base, recordHash: record.recordHash };
|
|
681
|
+
}
|
|
682
|
+
function validateAuditChain(anchor, audit) {
|
|
683
|
+
let previous = anchor;
|
|
684
|
+
let sequence = 1;
|
|
685
|
+
for (const record of audit) {
|
|
686
|
+
if (record.sequence !== sequence || record.previousHash !== previous)
|
|
687
|
+
throw new SafetyControllerError("Safety audit chain is invalid.");
|
|
688
|
+
previous = record.recordHash;
|
|
689
|
+
sequence += 1;
|
|
420
690
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
function
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
function
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
691
|
+
}
|
|
692
|
+
function setSwitch(state, scope, enabled, reason, principalId, now) {
|
|
693
|
+
const existing = state.switches.find((candidate) => sameScope(candidate.scope, scope));
|
|
694
|
+
if (existing === undefined) {
|
|
695
|
+
if (state.switches.length >= MAX_CONTROLS)
|
|
696
|
+
throw new SafetyControllerError("Safety switch limit was reached.");
|
|
697
|
+
state.switches.push({
|
|
698
|
+
scope,
|
|
699
|
+
enabled,
|
|
700
|
+
reason,
|
|
701
|
+
changedBy: principalId,
|
|
702
|
+
changedAt: now,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
existing.enabled = enabled;
|
|
707
|
+
existing.reason = reason;
|
|
708
|
+
existing.changedBy = principalId;
|
|
709
|
+
existing.changedAt = now;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
function assertActionAllowed(state, action, now) {
|
|
713
|
+
const stopped = state.switches.find((control) => control.enabled && scopeMatches(control.scope, action));
|
|
714
|
+
if (stopped !== undefined)
|
|
715
|
+
throw new SafetyControllerError(`Kill switch is active for ${scopeKey(stopped.scope)}.`, KILL_SWITCH_ERROR_CODE);
|
|
716
|
+
const circuit = state.circuits.find((candidate) => candidate.state === "open" && scopeMatches(candidate.scope, action));
|
|
717
|
+
if (circuit !== undefined)
|
|
718
|
+
throw new SafetyControllerError(`Safety circuit breaker is open: ${circuit.id}.`);
|
|
719
|
+
for (const circuitCandidate of state.circuits)
|
|
720
|
+
if (circuitCandidate.state === "closed" &&
|
|
721
|
+
scopeMatches(circuitCandidate.scope, action))
|
|
722
|
+
evaluateCircuit(state, circuitCandidate, now);
|
|
723
|
+
const opened = state.circuits.find((candidate) => candidate.state === "open" && scopeMatches(candidate.scope, action));
|
|
724
|
+
if (opened !== undefined)
|
|
725
|
+
throw new SafetyControllerError(`Safety circuit breaker is open: ${opened.id}.`);
|
|
726
|
+
}
|
|
727
|
+
function assertBudgets(state, action, now) {
|
|
728
|
+
for (const budget of state.budgets) {
|
|
729
|
+
if (!scopeMatches(budget.scope, action))
|
|
730
|
+
continue;
|
|
731
|
+
const windowEntries = state.ledger.filter((entry) => scopeMatches(budget.scope, entry.action) &&
|
|
732
|
+
Date.parse(entry.admittedAt) >= Date.parse(now) - budget.windowMs);
|
|
733
|
+
const velocityEntries = state.ledger.filter((entry) => scopeMatches(budget.scope, entry.action) &&
|
|
734
|
+
Date.parse(entry.admittedAt) >=
|
|
735
|
+
Date.parse(now) - budget.velocityWindowMs);
|
|
736
|
+
const totals = sumCosts(windowEntries.map((entry) => entry.action.costs));
|
|
737
|
+
const velocity = sumCosts(velocityEntries.map((entry) => entry.action.costs)).requests;
|
|
738
|
+
const concurrent = windowEntries.filter((entry) => entry.status === "reserved").length;
|
|
739
|
+
for (const key of [
|
|
740
|
+
"monetaryAmountMinor",
|
|
741
|
+
"requests",
|
|
742
|
+
"messages",
|
|
743
|
+
"mutations",
|
|
744
|
+
"deletes",
|
|
745
|
+
"tokenCost",
|
|
746
|
+
])
|
|
747
|
+
if (budget.limits[key] !== undefined &&
|
|
748
|
+
totals[key] + action.costs[key] > budget.limits[key])
|
|
749
|
+
throw new SafetyControllerError(`Safety budget exceeded: ${budget.id} ${key}.`);
|
|
750
|
+
if (budget.limits.concurrency !== undefined &&
|
|
751
|
+
concurrent + 1 > budget.limits.concurrency)
|
|
752
|
+
throw new SafetyControllerError(`Safety budget exceeded: ${budget.id} concurrency.`);
|
|
753
|
+
if (budget.limits.velocity !== undefined &&
|
|
754
|
+
velocity + action.costs.requests > budget.limits.velocity)
|
|
755
|
+
throw new SafetyControllerError(`Safety velocity limit exceeded: ${budget.id}.`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
function evaluateCircuits(state, action, now) {
|
|
759
|
+
for (const circuit of state.circuits)
|
|
760
|
+
if (circuit.state === "closed" && scopeMatches(circuit.scope, action))
|
|
761
|
+
evaluateCircuit(state, circuit, now);
|
|
762
|
+
}
|
|
763
|
+
function evaluateCircuit(state, circuit, now) {
|
|
764
|
+
const entries = state.ledger.filter((entry) => entry.status === "completed" &&
|
|
765
|
+
entry.completedAt !== undefined &&
|
|
766
|
+
entry.outcome !== undefined &&
|
|
767
|
+
scopeMatches(circuit.scope, entry.action) &&
|
|
768
|
+
Date.parse(entry.completedAt) >= Date.parse(now) - circuit.windowMs);
|
|
769
|
+
if (entries.length < circuit.minimumSamples)
|
|
770
|
+
return;
|
|
771
|
+
const failures = entries.filter((entry) => !entry.outcome.success).length;
|
|
772
|
+
const violations = entries.filter((entry) => entry.outcome.policyViolation).length;
|
|
773
|
+
const mismatches = entries.filter((entry) => entry.outcome.reconciliationMismatch).length;
|
|
774
|
+
const latencyExceeded = entries.some((entry) => entry.outcome.latencyMs > circuit.maxLatencyMs);
|
|
775
|
+
const reason = rateBps(failures, entries.length) > circuit.maxFailureRateBps
|
|
776
|
+
? "failure rate exceeded"
|
|
777
|
+
: violations > circuit.maxPolicyViolations
|
|
778
|
+
? "policy violation threshold exceeded"
|
|
779
|
+
: mismatches > circuit.maxReconciliationMismatches
|
|
780
|
+
? "reconciliation mismatch threshold exceeded"
|
|
781
|
+
: latencyExceeded
|
|
782
|
+
? "latency threshold exceeded"
|
|
783
|
+
: undefined;
|
|
784
|
+
if (reason !== undefined) {
|
|
785
|
+
circuit.state = "open";
|
|
786
|
+
circuit.openedAt = now;
|
|
787
|
+
circuit.reason = reason;
|
|
788
|
+
appendAudit(state, "circuit.opened", circuit.id, reason, now);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
function deadLetterMatchingQueued(state, scope, reason, now) {
|
|
792
|
+
const remaining = [];
|
|
793
|
+
for (const item of state.queue) {
|
|
794
|
+
if (scopeMatches(scope, item.action)) {
|
|
795
|
+
addDeadLetter(state, item, `kill switch: ${reason}`, now);
|
|
796
|
+
appendAudit(state, "queue.dead_lettered", item.id, "kill switch stopped queued action", now);
|
|
797
|
+
}
|
|
798
|
+
else
|
|
799
|
+
remaining.push(item);
|
|
800
|
+
}
|
|
801
|
+
state.queue = remaining;
|
|
802
|
+
}
|
|
803
|
+
function addDeadLetter(state, item, reason, now) {
|
|
804
|
+
if (state.deadLetters.length >= MAX_DEAD_LETTERS)
|
|
805
|
+
state.deadLetters.shift();
|
|
806
|
+
state.deadLetters.push({
|
|
807
|
+
...item,
|
|
808
|
+
reason: text(reason, "Safety dead-letter reason", 200),
|
|
809
|
+
deadLetteredAt: now,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
function pruneExpiredLeases(state, now, leaseTtlMs) {
|
|
813
|
+
const cutoff = Date.parse(now) - leaseTtlMs;
|
|
814
|
+
state.ledger = state.ledger.filter((entry) => entry.status === "completed" || Date.parse(entry.admittedAt) >= cutoff);
|
|
815
|
+
}
|
|
816
|
+
function findLedger(state, key) {
|
|
817
|
+
const entry = state.ledger.find((candidate) => candidate.key === key);
|
|
818
|
+
if (entry === undefined)
|
|
819
|
+
throw new SafetyControllerError("Safety lease was not found.");
|
|
820
|
+
return entry;
|
|
821
|
+
}
|
|
822
|
+
function scopeMatches(scope, action) {
|
|
823
|
+
return scope.kind === "global" || scope.id === scopeValue(scope.kind, action);
|
|
824
|
+
}
|
|
825
|
+
function scopeValue(kind, action) {
|
|
826
|
+
switch (kind) {
|
|
827
|
+
case "organization":
|
|
828
|
+
return action.organizationId;
|
|
829
|
+
case "project":
|
|
830
|
+
return action.projectId;
|
|
831
|
+
case "environment":
|
|
832
|
+
return action.environment;
|
|
833
|
+
case "agent":
|
|
834
|
+
return action.actorId;
|
|
835
|
+
case "workload":
|
|
836
|
+
return action.workloadId;
|
|
837
|
+
case "provider":
|
|
838
|
+
return action.provider;
|
|
839
|
+
case "operation":
|
|
840
|
+
return action.operation;
|
|
841
|
+
case "risk_class":
|
|
842
|
+
return action.riskClass;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function scopeKey(scope) {
|
|
846
|
+
return scope.kind === "global" ? "global" : `${scope.kind}:${scope.id}`;
|
|
847
|
+
}
|
|
848
|
+
function sameScope(left, right) {
|
|
849
|
+
return left.kind === right.kind && left.id === right.id;
|
|
850
|
+
}
|
|
851
|
+
function ledgerKey(action) {
|
|
852
|
+
return `${action.idempotencyKey}:${action.actionHash}`;
|
|
853
|
+
}
|
|
854
|
+
function sumCosts(values) {
|
|
855
|
+
return values.reduce((total, value) => ({
|
|
856
|
+
monetaryAmountMinor: total.monetaryAmountMinor + value.monetaryAmountMinor,
|
|
857
|
+
requests: total.requests + value.requests,
|
|
858
|
+
messages: total.messages + value.messages,
|
|
859
|
+
mutations: total.mutations + value.mutations,
|
|
860
|
+
deletes: total.deletes + value.deletes,
|
|
861
|
+
tokenCost: total.tokenCost + value.tokenCost,
|
|
862
|
+
}), {
|
|
863
|
+
monetaryAmountMinor: 0,
|
|
864
|
+
requests: 0,
|
|
865
|
+
messages: 0,
|
|
866
|
+
mutations: 0,
|
|
867
|
+
deletes: 0,
|
|
868
|
+
tokenCost: 0,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
function appendAudit(state, event, subject, reason, timestampValue, actorPrincipalId) {
|
|
872
|
+
if (state.audit.length >= MAX_AUDIT)
|
|
873
|
+
throw new SafetyControllerError("Safety audit limit was reached.");
|
|
874
|
+
const previousHash = state.audit.at(-1)?.recordHash ?? state.auditAnchor;
|
|
875
|
+
const base = {
|
|
876
|
+
sequence: state.audit.length + 1,
|
|
877
|
+
event: text(event, "Safety audit event", 80),
|
|
878
|
+
subject: text(subject, "Safety audit subject", 300),
|
|
879
|
+
reason: text(reason, "Safety audit reason", 200),
|
|
880
|
+
...(actorPrincipalId === undefined
|
|
881
|
+
? {}
|
|
882
|
+
: {
|
|
883
|
+
actorPrincipalId: identifier(actorPrincipalId, "Safety audit actor"),
|
|
884
|
+
}),
|
|
885
|
+
timestamp: timestampValue,
|
|
886
|
+
previousHash,
|
|
887
|
+
};
|
|
888
|
+
state.audit.push({ ...base, recordHash: sha256(canonical(base)) });
|
|
889
|
+
}
|
|
890
|
+
function boundedMs(value, label) {
|
|
891
|
+
return positive(value, label, 30 * 24 * 60 * 60 * 1000);
|
|
892
|
+
}
|
|
893
|
+
function positive(value, label, max) {
|
|
894
|
+
if (typeof value !== "number" ||
|
|
895
|
+
!Number.isInteger(value) ||
|
|
896
|
+
value < 1 ||
|
|
897
|
+
value > max)
|
|
898
|
+
throw new SafetyControllerError(`${label} is invalid.`);
|
|
899
|
+
return value;
|
|
900
|
+
}
|
|
901
|
+
function nonNegative(value, label, max) {
|
|
902
|
+
if (typeof value !== "number" ||
|
|
903
|
+
!Number.isInteger(value) ||
|
|
904
|
+
value < 0 ||
|
|
905
|
+
value > max)
|
|
906
|
+
throw new SafetyControllerError(`${label} is invalid.`);
|
|
907
|
+
return value;
|
|
908
|
+
}
|
|
909
|
+
function bps(value, label) {
|
|
910
|
+
return nonNegative(value, label, 10_000);
|
|
911
|
+
}
|
|
912
|
+
function rateBps(count, total) {
|
|
913
|
+
return total === 0 ? 0 : Math.floor((count * 10_000) / total);
|
|
914
|
+
}
|
|
915
|
+
function identifier(value, label) {
|
|
916
|
+
if (typeof value !== "string" ||
|
|
917
|
+
!IDENTIFIER.test(value) ||
|
|
918
|
+
sanitizeSecretString(value) !== value)
|
|
919
|
+
throw new SafetyControllerError(`${label} must be a safe identifier.`);
|
|
920
|
+
return value;
|
|
921
|
+
}
|
|
922
|
+
function hash(value, label) {
|
|
923
|
+
if (typeof value !== "string" || !HASH.test(value))
|
|
924
|
+
throw new SafetyControllerError(`${label} must be a SHA-256 hash.`);
|
|
925
|
+
return value;
|
|
926
|
+
}
|
|
927
|
+
function timestamp(value, label) {
|
|
928
|
+
if (typeof value !== "string" ||
|
|
929
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) ||
|
|
930
|
+
!Number.isFinite(Date.parse(value)))
|
|
931
|
+
throw new SafetyControllerError(`${label} must be an ISO UTC timestamp.`);
|
|
932
|
+
return value;
|
|
933
|
+
}
|
|
934
|
+
function text(value, label, max) {
|
|
935
|
+
if (typeof value !== "string" ||
|
|
936
|
+
value.trim() === "" ||
|
|
937
|
+
value.length > max ||
|
|
938
|
+
/[\u0000-\u001f]/.test(value) ||
|
|
939
|
+
sanitizeSecretString(value) !== value)
|
|
940
|
+
throw new SafetyControllerError(`${label} is invalid.`);
|
|
941
|
+
return value.trim();
|
|
942
|
+
}
|
|
943
|
+
function object(value, message) {
|
|
944
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
945
|
+
throw new SafetyControllerError(message);
|
|
946
|
+
return value;
|
|
947
|
+
}
|
|
948
|
+
function array(value, label, max) {
|
|
949
|
+
if (!Array.isArray(value) || value.length > max)
|
|
950
|
+
throw new SafetyControllerError(`${label} is invalid.`);
|
|
951
|
+
return value;
|
|
952
|
+
}
|
|
953
|
+
function exactKeys(value, keys, label, optional = []) {
|
|
954
|
+
for (const key of Object.keys(value))
|
|
955
|
+
if (!keys.includes(key) ||
|
|
956
|
+
(value[key] === undefined && !optional.includes(key)))
|
|
957
|
+
throw new SafetyControllerError(`${label} contains unsupported field: ${key}`);
|
|
958
|
+
}
|
|
959
|
+
function unique(values, label) {
|
|
960
|
+
if (new Set(values).size !== values.length)
|
|
961
|
+
throw new SafetyControllerError(`${label} must be unique.`);
|
|
962
|
+
}
|
|
963
|
+
function canonical(value) {
|
|
964
|
+
if (value === null ||
|
|
965
|
+
typeof value === "boolean" ||
|
|
966
|
+
typeof value === "string" ||
|
|
967
|
+
typeof value === "number")
|
|
968
|
+
return JSON.stringify(value);
|
|
969
|
+
if (Array.isArray(value))
|
|
970
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
971
|
+
if (typeof value === "object")
|
|
972
|
+
return `{${Object.keys(value)
|
|
973
|
+
.sort()
|
|
974
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
|
|
975
|
+
.join(",")}}`;
|
|
976
|
+
throw new SafetyControllerError("Safety data must be JSON.");
|
|
977
|
+
}
|
|
978
|
+
function sha256(value) {
|
|
979
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
980
|
+
}
|
|
981
|
+
function clone(value) {
|
|
982
|
+
return structuredClone(value);
|
|
983
|
+
}
|
|
984
|
+
function isErrorCode(error, code) {
|
|
985
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
986
|
+
}
|
|
477
987
|
//# sourceMappingURL=safetyController.js.map
|