@synapsor/runner 0.1.8 → 0.1.10
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 +20 -0
- package/README.md +11 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/runner.mjs +468 -29
- package/docs/local-mode.md +4 -3
- package/docs/release-notes.md +9 -0
- package/examples/support-plan-credit/README.md +176 -0
- package/examples/support-plan-credit/contract.synapsor +59 -0
- package/examples/support-plan-credit/docker-compose.yml +12 -0
- package/examples/support-plan-credit/seed/001_seed.sql +92 -0
- package/examples/support-plan-credit/synapsor.contract.json +198 -0
- package/examples/support-plan-credit/synapsor.runner.json +18 -0
- package/package.json +2 -1
package/dist/runner.mjs
CHANGED
|
@@ -444,8 +444,9 @@ function sleep(ms) {
|
|
|
444
444
|
}
|
|
445
445
|
|
|
446
446
|
// packages/config/src/index.ts
|
|
447
|
-
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "contracts", "cloud", "strict", "result_format"]);
|
|
447
|
+
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "contracts", "policies", "approvals", "cloud", "strict", "result_format"]);
|
|
448
448
|
var STORAGE_KEYS = /* @__PURE__ */ new Set(["sqlite_path"]);
|
|
449
|
+
var APPROVALS_KEYS = /* @__PURE__ */ new Set(["disable_auto_approval"]);
|
|
449
450
|
var CLOUD_KEYS = /* @__PURE__ */ new Set(["base_url_env", "runner_token_env", "runner_id", "runner_version", "project_id", "adapter_id", "source_id", "engines", "capabilities", "session"]);
|
|
450
451
|
var SOURCE_KEYS = /* @__PURE__ */ new Set([
|
|
451
452
|
"engine",
|
|
@@ -489,7 +490,9 @@ var PATCH_BINDING_KEYS = /* @__PURE__ */ new Set(["fixed", "from_arg"]);
|
|
|
489
490
|
var NUMERIC_BOUND_KEYS = /* @__PURE__ */ new Set(["minimum", "maximum"]);
|
|
490
491
|
var TRANSITION_GUARD_KEYS = /* @__PURE__ */ new Set(["from_column", "allowed"]);
|
|
491
492
|
var CONFLICT_GUARD_KEYS = /* @__PURE__ */ new Set(["column", "weak_guard_ack"]);
|
|
492
|
-
var APPROVAL_KEYS = /* @__PURE__ */ new Set(["mode", "required_role"]);
|
|
493
|
+
var APPROVAL_KEYS = /* @__PURE__ */ new Set(["mode", "required_role", "policy"]);
|
|
494
|
+
var POLICY_KEYS = /* @__PURE__ */ new Set(["name", "kind", "mode", "rules"]);
|
|
495
|
+
var APPROVAL_POLICY_RULE_KEYS = /* @__PURE__ */ new Set(["field", "max"]);
|
|
493
496
|
var WRITEBACK_KEYS = /* @__PURE__ */ new Set(["mode", "executor"]);
|
|
494
497
|
var WRITEBACK_MODES = /* @__PURE__ */ new Set(["direct_sql", "app_handler", "cloud_worker", "none"]);
|
|
495
498
|
var MODEL_CONTROLLED_TRUST_FIELDS = /* @__PURE__ */ new Set([
|
|
@@ -562,17 +565,60 @@ function validateRunnerCapabilityConfig(input) {
|
|
|
562
565
|
errors.push({ path: "$.mode", code: "INVALID_MODE", message: "mode must be read_only, shadow, review, or cloud." });
|
|
563
566
|
}
|
|
564
567
|
validateStorage(input.storage, strict, errors);
|
|
568
|
+
validateApprovals(input.approvals, strict, errors);
|
|
565
569
|
const hasContracts = validateContracts(input.contracts, errors);
|
|
566
570
|
validateCloud(input.cloud, input.mode, strict, errors);
|
|
567
571
|
validateSources(input.sources, input.mode, strict, errors, warnings);
|
|
568
572
|
validateContexts(input.contexts, strict, errors, warnings);
|
|
569
573
|
validateTrustedContext(input.trusted_context, input.contexts, input.capabilities, input.mode, strict, errors, warnings, hasContracts);
|
|
570
574
|
validateExecutors(input.executors, input.mode, strict, errors);
|
|
575
|
+
validatePolicies(input.policies, strict, errors);
|
|
571
576
|
validateCapabilities(input.capabilities, input.sources, input.contexts, input.executors, input.mode, strict, errors, warnings, hasContracts);
|
|
577
|
+
validateApprovalPolicyReferences(input.capabilities, input.policies, errors);
|
|
572
578
|
validateWritebackReadiness(input.sources, input.capabilities, input.mode, errors, warnings);
|
|
573
579
|
scanForForbiddenFields(input, "$", errors);
|
|
574
580
|
return { ok: errors.length === 0, errors, warnings };
|
|
575
581
|
}
|
|
582
|
+
function validateApprovalPolicyReferences(capabilities, policies, errors) {
|
|
583
|
+
if (!Array.isArray(capabilities)) return;
|
|
584
|
+
const policyByName = /* @__PURE__ */ new Map();
|
|
585
|
+
if (Array.isArray(policies)) {
|
|
586
|
+
policies.forEach((policy) => {
|
|
587
|
+
if (isRecord(policy) && typeof policy.name === "string") policyByName.set(policy.name, policy);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
capabilities.forEach((capability, index) => {
|
|
591
|
+
if (!isRecord(capability) || capability.kind !== "proposal" || !isRecord(capability.approval)) return;
|
|
592
|
+
const approval = capability.approval;
|
|
593
|
+
const path4 = `$.capabilities[${index}].approval`;
|
|
594
|
+
if (approval.mode === "policy") {
|
|
595
|
+
if (!isNonEmptyString(approval.required_role)) {
|
|
596
|
+
errors.push({ path: `${path4}.required_role`, code: "APPROVAL_POLICY_ROLE_REQUIRED", message: "policy approval still requires required_role for human fallback." });
|
|
597
|
+
}
|
|
598
|
+
if (!isSafeName(approval.policy)) {
|
|
599
|
+
errors.push({ path: `${path4}.policy`, code: "APPROVAL_POLICY_REQUIRED", message: "approval.mode policy requires approval.policy." });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const policy = policyByName.get(String(approval.policy));
|
|
603
|
+
if (!policy || policy.kind !== "approval") {
|
|
604
|
+
errors.push({ path: `${path4}.policy`, code: "APPROVAL_POLICY_UNRESOLVED", message: "approval.policy must reference a top-level approval policy." });
|
|
605
|
+
}
|
|
606
|
+
} else if (approval.policy !== void 0) {
|
|
607
|
+
errors.push({ path: `${path4}.policy`, code: "APPROVAL_POLICY_MODE_REQUIRED", message: "approval.policy can only be set when approval.mode is policy." });
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
function validateApprovals(value, strict, errors) {
|
|
612
|
+
if (value === void 0) return;
|
|
613
|
+
if (!isRecord(value)) {
|
|
614
|
+
errors.push({ path: "$.approvals", code: "APPROVALS_NOT_OBJECT", message: "approvals must be an object." });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (strict) checkUnknownKeys(value, APPROVALS_KEYS, "$.approvals", errors);
|
|
618
|
+
if (value.disable_auto_approval !== void 0 && typeof value.disable_auto_approval !== "boolean") {
|
|
619
|
+
errors.push({ path: "$.approvals.disable_auto_approval", code: "INVALID_DISABLE_AUTO_APPROVAL", message: "disable_auto_approval must be true or false." });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
576
622
|
function validateContracts(value, errors) {
|
|
577
623
|
if (value === void 0) return false;
|
|
578
624
|
if (!Array.isArray(value) || value.length === 0) {
|
|
@@ -873,6 +919,44 @@ function validateExecutors(value, mode, strict, errors) {
|
|
|
873
919
|
}
|
|
874
920
|
}
|
|
875
921
|
}
|
|
922
|
+
function validatePolicies(value, strict, errors) {
|
|
923
|
+
if (value === void 0) return;
|
|
924
|
+
if (!Array.isArray(value)) {
|
|
925
|
+
errors.push({ path: "$.policies", code: "POLICIES_NOT_ARRAY", message: "policies must be an array." });
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const names = /* @__PURE__ */ new Set();
|
|
929
|
+
value.forEach((policy, index) => {
|
|
930
|
+
const path4 = `$.policies[${index}]`;
|
|
931
|
+
if (!isRecord(policy)) {
|
|
932
|
+
errors.push({ path: path4, code: "POLICY_NOT_OBJECT", message: "policy must be an object." });
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (strict) checkUnknownKeys(policy, POLICY_KEYS, path4, errors);
|
|
936
|
+
if (!isSafeName(policy.name)) errors.push({ path: `${path4}.name`, code: "INVALID_POLICY_NAME", message: "policy name must be a safe or qualified identifier." });
|
|
937
|
+
else if (names.has(String(policy.name))) errors.push({ path: `${path4}.name`, code: "DUPLICATE_POLICY_NAME", message: `Duplicate policy name: ${String(policy.name)}` });
|
|
938
|
+
else names.add(String(policy.name));
|
|
939
|
+
if (!["approval", "settlement", "scope", "custom"].includes(String(policy.kind))) {
|
|
940
|
+
errors.push({ path: `${path4}.kind`, code: "INVALID_POLICY_KIND", message: "policy.kind must be approval, settlement, scope, or custom." });
|
|
941
|
+
}
|
|
942
|
+
if (policy.rules !== void 0) {
|
|
943
|
+
if (!Array.isArray(policy.rules)) {
|
|
944
|
+
errors.push({ path: `${path4}.rules`, code: "POLICY_RULES_NOT_ARRAY", message: "policy.rules must be an array." });
|
|
945
|
+
} else if (policy.kind === "approval") {
|
|
946
|
+
policy.rules.forEach((rule, ruleIndex) => validateApprovalPolicyRule(rule, `${path4}.rules[${ruleIndex}]`, strict, errors));
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
function validateApprovalPolicyRule(value, path4, strict, errors) {
|
|
952
|
+
if (!isRecord(value)) {
|
|
953
|
+
errors.push({ path: path4, code: "APPROVAL_POLICY_RULE_NOT_OBJECT", message: "approval policy rules must be objects." });
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (strict) checkUnknownKeys(value, APPROVAL_POLICY_RULE_KEYS, path4, errors);
|
|
957
|
+
if (!isSafeIdentifier(value.field)) errors.push({ path: `${path4}.field`, code: "INVALID_APPROVAL_POLICY_FIELD", message: "approval policy rule field must be a safe identifier." });
|
|
958
|
+
if (!Number.isInteger(value.max) || Number(value.max) < 0) errors.push({ path: `${path4}.max`, code: "INVALID_APPROVAL_POLICY_MAX", message: "approval policy rule max must be a non-negative integer." });
|
|
959
|
+
}
|
|
876
960
|
function validateExecutorAuth(value, path4, strict, errors) {
|
|
877
961
|
if (value === void 0) return;
|
|
878
962
|
if (!isRecord(value)) {
|
|
@@ -2823,10 +2907,10 @@ var PATCH_KEYS = /* @__PURE__ */ new Set(["fixed", "from_arg"]);
|
|
|
2823
2907
|
var NUMERIC_BOUND_KEYS2 = /* @__PURE__ */ new Set(["minimum", "maximum"]);
|
|
2824
2908
|
var TRANSITION_GUARD_KEYS2 = /* @__PURE__ */ new Set(["from_column", "allowed"]);
|
|
2825
2909
|
var CONFLICT_GUARD_KEYS2 = /* @__PURE__ */ new Set(["column", "weak_guard_ack"]);
|
|
2826
|
-
var APPROVAL_KEYS2 = /* @__PURE__ */ new Set(["mode", "required_role"]);
|
|
2910
|
+
var APPROVAL_KEYS2 = /* @__PURE__ */ new Set(["mode", "required_role", "policy"]);
|
|
2827
2911
|
var WRITEBACK_KEYS2 = /* @__PURE__ */ new Set(["mode", "executor", "idempotency_key"]);
|
|
2828
2912
|
var WORKFLOW_KEYS = /* @__PURE__ */ new Set(["name", "description", "context", "allowed_capabilities", "required_evidence", "approval", "settlement", "replay"]);
|
|
2829
|
-
var
|
|
2913
|
+
var POLICY_KEYS2 = /* @__PURE__ */ new Set(["name", "kind", "mode", "rules"]);
|
|
2830
2914
|
var EVIDENCE_RECORD_KEYS = /* @__PURE__ */ new Set(["handle", "capability", "query_fingerprint", "items"]);
|
|
2831
2915
|
var PROPOSAL_RECORD_KEYS = /* @__PURE__ */ new Set(["id", "capability", "subject", "status", "diff", "evidence_handle"]);
|
|
2832
2916
|
var RECEIPT_KEYS = /* @__PURE__ */ new Set(["id", "proposal_id", "status", "idempotency_key", "source_database_mutated", "rows_affected"]);
|
|
@@ -2868,9 +2952,12 @@ function validateContract(input) {
|
|
|
2868
2952
|
validateMetadata(input.metadata, "$.metadata", errors);
|
|
2869
2953
|
const resourceNames = validateResources(input.resources, errors, warnings);
|
|
2870
2954
|
const contextNames = validateContexts2(input.contexts, errors, warnings);
|
|
2955
|
+
const capabilities = Array.isArray(input.capabilities) ? input.capabilities : [];
|
|
2956
|
+
const policies = Array.isArray(input.policies) ? input.policies : [];
|
|
2871
2957
|
const capabilityNames = validateCapabilities2(input.capabilities, contextNames, resourceNames, errors, warnings);
|
|
2872
2958
|
validateWorkflows(input.workflows, contextNames, capabilityNames, errors);
|
|
2873
|
-
|
|
2959
|
+
const policyByName = validatePolicies2(input.policies, errors);
|
|
2960
|
+
validateCapabilityApprovalPolicies(capabilities, policies, policyByName, errors);
|
|
2874
2961
|
validateEvidenceRecords(input.evidence, errors);
|
|
2875
2962
|
validateProposalRecords(input.proposals, capabilityNames, errors);
|
|
2876
2963
|
validateReceiptRecords(input.receipts, errors);
|
|
@@ -3248,10 +3335,135 @@ function validateWorkflows(value, contextNames, capabilityNames, errors) {
|
|
|
3248
3335
|
}
|
|
3249
3336
|
});
|
|
3250
3337
|
}
|
|
3251
|
-
function
|
|
3338
|
+
function validatePolicies2(value, errors) {
|
|
3339
|
+
const names = /* @__PURE__ */ new Map();
|
|
3252
3340
|
if (value === void 0)
|
|
3341
|
+
return names;
|
|
3342
|
+
if (!Array.isArray(value)) {
|
|
3343
|
+
errors.push({ path: "$.policies", code: "POLICIES_NOT_ARRAY", message: "$.policies must be an array." });
|
|
3344
|
+
return names;
|
|
3345
|
+
}
|
|
3346
|
+
value.forEach((policy, index) => {
|
|
3347
|
+
const path4 = `$.policies[${index}]`;
|
|
3348
|
+
if (!isRecord3(policy)) {
|
|
3349
|
+
errors.push({ path: path4, code: "POLICY_NOT_OBJECT", message: "policy entry must be an object." });
|
|
3350
|
+
return;
|
|
3351
|
+
}
|
|
3352
|
+
checkUnknownKeys2(policy, POLICY_KEYS2, path4, errors);
|
|
3353
|
+
if (!isQualifiedOrSafeName(policy.name))
|
|
3354
|
+
errors.push({ path: `${path4}.name`, code: "INVALID_POLICY_NAME", message: "policy name must be a safe identifier or qualified name." });
|
|
3355
|
+
else {
|
|
3356
|
+
if (names.has(String(policy.name)))
|
|
3357
|
+
errors.push({ path: `${path4}.name`, code: "DUPLICATE_POLICY_NAME", message: `Duplicate name: ${String(policy.name)}` });
|
|
3358
|
+
names.set(String(policy.name), policy);
|
|
3359
|
+
}
|
|
3360
|
+
if (!["approval", "settlement", "scope", "custom"].includes(String(policy.kind)))
|
|
3361
|
+
errors.push({ path: `${path4}.kind`, code: "INVALID_POLICY_KIND", message: "policy.kind must be approval, settlement, scope, or custom." });
|
|
3362
|
+
if (policy.mode !== void 0 && !["green", "yellow", "red", "manual", "block"].includes(String(policy.mode)))
|
|
3363
|
+
errors.push({ path: `${path4}.mode`, code: "INVALID_POLICY_MODE", message: "policy.mode must be green, yellow, red, manual, or block." });
|
|
3364
|
+
if (policy.rules !== void 0) {
|
|
3365
|
+
if (!Array.isArray(policy.rules)) {
|
|
3366
|
+
errors.push({ path: `${path4}.rules`, code: "POLICY_RULES_NOT_ARRAY", message: "policy.rules must be an array." });
|
|
3367
|
+
} else if (policy.kind === "approval") {
|
|
3368
|
+
policy.rules.forEach((rule, ruleIndex) => validateApprovalPolicyRuleShape(rule, `${path4}.rules[${ruleIndex}]`, errors));
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
});
|
|
3372
|
+
return names;
|
|
3373
|
+
}
|
|
3374
|
+
function validateApprovalPolicyRuleShape(rule, path4, errors) {
|
|
3375
|
+
if (!isRecord3(rule)) {
|
|
3376
|
+
errors.push({ path: path4, code: "APPROVAL_POLICY_RULE_NOT_OBJECT", message: "approval policy rules must be objects." });
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
const keys = /* @__PURE__ */ new Set(["field", "max"]);
|
|
3380
|
+
checkUnknownKeys2(rule, keys, path4, errors);
|
|
3381
|
+
if (!isSafeIdentifier2(rule.field))
|
|
3382
|
+
errors.push({ path: `${path4}.field`, code: "INVALID_APPROVAL_POLICY_FIELD", message: "approval policy rule field must be a safe identifier." });
|
|
3383
|
+
if (!Number.isInteger(rule.max) || Number(rule.max) < 0)
|
|
3384
|
+
errors.push({ path: `${path4}.max`, code: "INVALID_APPROVAL_POLICY_MAX", message: "approval policy rule max must be a non-negative integer." });
|
|
3385
|
+
}
|
|
3386
|
+
function validateCapabilityApprovalPolicies(capabilities, policies, policyByName, errors) {
|
|
3387
|
+
const policyIndexByName = /* @__PURE__ */ new Map();
|
|
3388
|
+
policies.forEach((policy, index) => {
|
|
3389
|
+
if (isRecord3(policy) && typeof policy.name === "string")
|
|
3390
|
+
policyIndexByName.set(policy.name, index);
|
|
3391
|
+
});
|
|
3392
|
+
capabilities.forEach((capability, index) => {
|
|
3393
|
+
if (!isRecord3(capability) || !isRecord3(capability.proposal))
|
|
3394
|
+
return;
|
|
3395
|
+
const proposal = capability.proposal;
|
|
3396
|
+
const approval = proposal.approval;
|
|
3397
|
+
if (!isRecord3(approval))
|
|
3398
|
+
return;
|
|
3399
|
+
const path4 = `$.capabilities[${index}].proposal.approval`;
|
|
3400
|
+
const mode = approval.mode;
|
|
3401
|
+
const policyName = approval.policy;
|
|
3402
|
+
if (mode === "policy") {
|
|
3403
|
+
if (!isNonEmptyString2(approval.required_role)) {
|
|
3404
|
+
errors.push({ path: `${path4}.required_role`, code: "APPROVAL_POLICY_ROLE_REQUIRED", message: "approval.mode policy still requires required_role for human fallback." });
|
|
3405
|
+
}
|
|
3406
|
+
if (!isQualifiedOrSafeName(policyName)) {
|
|
3407
|
+
errors.push({ path: `${path4}.policy`, code: "APPROVAL_POLICY_REQUIRED", message: "approval.mode policy requires approval.policy." });
|
|
3408
|
+
return;
|
|
3409
|
+
}
|
|
3410
|
+
const policy = policyByName.get(policyName);
|
|
3411
|
+
if (!policy) {
|
|
3412
|
+
errors.push({ path: `${path4}.policy`, code: "UNKNOWN_APPROVAL_POLICY", message: `approval.policy must reference an existing approval policy: ${policyName}` });
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
if (policy.kind !== "approval") {
|
|
3416
|
+
errors.push({ path: `${path4}.policy`, code: "APPROVAL_POLICY_KIND_REQUIRED", message: "approval.policy must reference a policy with kind approval." });
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
validateApprovalPolicyRulesAgainstCapability(policy, policyIndexByName.get(policyName) ?? 0, capability, index, errors);
|
|
3420
|
+
} else if (policyName !== void 0) {
|
|
3421
|
+
errors.push({ path: `${path4}.policy`, code: "APPROVAL_POLICY_MODE_REQUIRED", message: "approval.policy can only be set when approval.mode is policy." });
|
|
3422
|
+
}
|
|
3423
|
+
});
|
|
3424
|
+
}
|
|
3425
|
+
function validateApprovalPolicyRulesAgainstCapability(policy, policyIndex, capability, capabilityIndex, errors) {
|
|
3426
|
+
if (!Array.isArray(policy.rules))
|
|
3427
|
+
return;
|
|
3428
|
+
const proposal = capability.proposal;
|
|
3429
|
+
if (!proposal)
|
|
3253
3430
|
return;
|
|
3254
|
-
|
|
3431
|
+
policy.rules.forEach((rule, ruleIndex) => {
|
|
3432
|
+
if (!isRecord3(rule))
|
|
3433
|
+
return;
|
|
3434
|
+
const field = rule.field;
|
|
3435
|
+
const max = rule.max;
|
|
3436
|
+
const rulePath = `$.policies[${policyIndex}].rules[${ruleIndex}]`;
|
|
3437
|
+
if (!isSafeIdentifier2(field) || !Number.isInteger(max))
|
|
3438
|
+
return;
|
|
3439
|
+
if (!isNumericProposalField(proposal, capability.args, field)) {
|
|
3440
|
+
errors.push({
|
|
3441
|
+
path: `${rulePath}.field`,
|
|
3442
|
+
code: "APPROVAL_POLICY_FIELD_NOT_NUMERIC",
|
|
3443
|
+
message: `approval policy field ${field} must be numeric for capability ${capability.name}.`
|
|
3444
|
+
});
|
|
3445
|
+
}
|
|
3446
|
+
const bound = proposal.numeric_bounds?.[field];
|
|
3447
|
+
if (bound?.maximum !== void 0 && Number(max) > Number(bound.maximum)) {
|
|
3448
|
+
errors.push({
|
|
3449
|
+
path: `${rulePath}.max`,
|
|
3450
|
+
code: "APPROVAL_POLICY_MAX_EXCEEDS_BOUND",
|
|
3451
|
+
message: `approval policy max for ${field} must be <= numeric_bounds maximum on $.capabilities[${capabilityIndex}].proposal.numeric_bounds.${field}.maximum.`
|
|
3452
|
+
});
|
|
3453
|
+
}
|
|
3454
|
+
});
|
|
3455
|
+
}
|
|
3456
|
+
function isNumericProposalField(proposal, args, field) {
|
|
3457
|
+
if (proposal.numeric_bounds?.[field] !== void 0)
|
|
3458
|
+
return true;
|
|
3459
|
+
const patch = proposal.patch?.[field];
|
|
3460
|
+
if (!patch)
|
|
3461
|
+
return false;
|
|
3462
|
+
if (typeof patch.fixed === "number" && Number.isInteger(patch.fixed))
|
|
3463
|
+
return true;
|
|
3464
|
+
if (patch.from_arg && args[patch.from_arg]?.type === "number")
|
|
3465
|
+
return true;
|
|
3466
|
+
return false;
|
|
3255
3467
|
}
|
|
3256
3468
|
function validateEvidenceRecords(value, errors) {
|
|
3257
3469
|
if (value === void 0)
|
|
@@ -3455,18 +3667,23 @@ function loadRuntimeConfigFromFile(configPath = process.env.SYNAPSOR_MCP_CONFIG
|
|
|
3455
3667
|
function resolveRuntimeConfig(config, baseDir = process.cwd()) {
|
|
3456
3668
|
if (!Array.isArray(config.contracts) || config.contracts.length === 0) return config;
|
|
3457
3669
|
const seenCapabilities = /* @__PURE__ */ new Map();
|
|
3670
|
+
const seenPolicies = /* @__PURE__ */ new Map();
|
|
3458
3671
|
for (const [index, capability] of (config.capabilities ?? []).entries()) {
|
|
3459
3672
|
rememberCapabilityName(seenCapabilities, capability.name, `embedded capabilities[${index}]`);
|
|
3460
3673
|
}
|
|
3674
|
+
for (const [index, policy] of (config.policies ?? []).entries()) {
|
|
3675
|
+
rememberPolicyName(seenPolicies, policy.name, `embedded policies[${index}]`);
|
|
3676
|
+
}
|
|
3461
3677
|
const resolved = {
|
|
3462
3678
|
...config,
|
|
3463
3679
|
contexts: { ...config.contexts ?? {} },
|
|
3464
|
-
capabilities: [...config.capabilities ?? []]
|
|
3680
|
+
capabilities: [...config.capabilities ?? []],
|
|
3681
|
+
policies: [...config.policies ?? []]
|
|
3465
3682
|
};
|
|
3466
3683
|
for (const [contractIndex, contractPath] of config.contracts.entries()) {
|
|
3467
3684
|
const fullPath = path.resolve(baseDir, contractPath);
|
|
3468
3685
|
const contract = normalizeContract(JSON.parse(fs.readFileSync(fullPath, "utf8")));
|
|
3469
|
-
mergeContractIntoRuntimeConfig(resolved, contract, `contracts[${contractIndex}] ${contractPath}`, seenCapabilities);
|
|
3686
|
+
mergeContractIntoRuntimeConfig(resolved, contract, `contracts[${contractIndex}] ${contractPath}`, seenCapabilities, seenPolicies);
|
|
3470
3687
|
}
|
|
3471
3688
|
delete resolved.contracts;
|
|
3472
3689
|
return resolved;
|
|
@@ -3478,7 +3695,14 @@ function rememberCapabilityName(seen, name, origin) {
|
|
|
3478
3695
|
}
|
|
3479
3696
|
seen.set(name, origin);
|
|
3480
3697
|
}
|
|
3481
|
-
function
|
|
3698
|
+
function rememberPolicyName(seen, name, origin) {
|
|
3699
|
+
const previous = seen.get(name);
|
|
3700
|
+
if (previous) {
|
|
3701
|
+
throw new Error(`Duplicate policy ${name}: ${origin} conflicts with ${previous}. Policy names must be unique across embedded runner config and referenced contracts.`);
|
|
3702
|
+
}
|
|
3703
|
+
seen.set(name, origin);
|
|
3704
|
+
}
|
|
3705
|
+
function mergeContractIntoRuntimeConfig(config, contract, origin, seenCapabilities, seenPolicies) {
|
|
3482
3706
|
const resources = new Map((contract.resources ?? []).map((resource) => [resource.name, resource]));
|
|
3483
3707
|
for (const context of contract.contexts) {
|
|
3484
3708
|
if (!config.contexts) config.contexts = {};
|
|
@@ -3493,6 +3717,13 @@ function mergeContractIntoRuntimeConfig(config, contract, origin, seenCapabiliti
|
|
|
3493
3717
|
rememberCapabilityName(seenCapabilities, capability.name, `${origin} capabilities[${capabilityIndex}]`);
|
|
3494
3718
|
config.capabilities.push(runtimeCapabilityFromSpec(capability, resources, config));
|
|
3495
3719
|
}
|
|
3720
|
+
if (contract.policies?.length) {
|
|
3721
|
+
if (!config.policies) config.policies = [];
|
|
3722
|
+
for (const [policyIndex, policy] of contract.policies.entries()) {
|
|
3723
|
+
rememberPolicyName(seenPolicies, policy.name, `${origin} policies[${policyIndex}]`);
|
|
3724
|
+
config.policies.push(policy);
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3496
3727
|
}
|
|
3497
3728
|
function runtimeContextFromSpec(context) {
|
|
3498
3729
|
const tenantBinding = context.bindings.find((binding) => binding.name === context.tenant_binding) ?? context.bindings.find((binding) => binding.name === "tenant_id");
|
|
@@ -4285,6 +4516,7 @@ async function callConfiguredTool(input) {
|
|
|
4285
4516
|
}
|
|
4286
4517
|
if (capability.kind === "proposal" && input.config.mode === "review") {
|
|
4287
4518
|
assertProposalWritebackResolvable(input.config, capability);
|
|
4519
|
+
assertApprovalPolicyResolvable(input.config, capability);
|
|
4288
4520
|
}
|
|
4289
4521
|
const source = input.config.sources?.[capability.source];
|
|
4290
4522
|
if (!source) throw new McpRuntimeError("SOURCE_NOT_FOUND", `Unknown source: ${capability.source}`);
|
|
@@ -4380,6 +4612,13 @@ async function callConfiguredTool(input) {
|
|
|
4380
4612
|
objectId
|
|
4381
4613
|
});
|
|
4382
4614
|
const proposal = input.store.createProposal(changeSet);
|
|
4615
|
+
const approvalResult = maybeAutoApproveProposal({
|
|
4616
|
+
config: input.config,
|
|
4617
|
+
capability,
|
|
4618
|
+
store: input.store,
|
|
4619
|
+
proposal,
|
|
4620
|
+
patch: changeSet.patch
|
|
4621
|
+
});
|
|
4383
4622
|
input.store.recordEvidenceBundle({
|
|
4384
4623
|
evidence_bundle_id: evidenceBundleId,
|
|
4385
4624
|
proposal_id: proposal.proposal_id,
|
|
@@ -4388,7 +4627,7 @@ async function callConfiguredTool(input) {
|
|
|
4388
4627
|
capability: capability.name,
|
|
4389
4628
|
proposal_id: proposal.proposal_id,
|
|
4390
4629
|
source_database_changed: false,
|
|
4391
|
-
approval_status: changeSet.approval.status
|
|
4630
|
+
approval_status: approvalResult.proposal.state === "approved" ? "approved" : changeSet.approval.status
|
|
4392
4631
|
},
|
|
4393
4632
|
items: [
|
|
4394
4633
|
{
|
|
@@ -4413,11 +4652,11 @@ async function callConfiguredTool(input) {
|
|
|
4413
4652
|
}
|
|
4414
4653
|
});
|
|
4415
4654
|
return {
|
|
4416
|
-
status: input.config.mode === "shadow" ? "shadow_proposal_created" : "review_required",
|
|
4655
|
+
status: input.config.mode === "shadow" ? "shadow_proposal_created" : approvalResult.proposal.state === "approved" ? "approved" : "review_required",
|
|
4417
4656
|
action: capability.name,
|
|
4418
|
-
proposal_id: proposal.proposal_id,
|
|
4419
|
-
proposal_version: proposal.proposal_version,
|
|
4420
|
-
proposal_hash: proposal.proposal_hash,
|
|
4657
|
+
proposal_id: approvalResult.proposal.proposal_id,
|
|
4658
|
+
proposal_version: approvalResult.proposal.proposal_version,
|
|
4659
|
+
proposal_hash: approvalResult.proposal.proposal_hash,
|
|
4421
4660
|
target: {
|
|
4422
4661
|
type: capability.target.table,
|
|
4423
4662
|
id: objectId,
|
|
@@ -4428,7 +4667,12 @@ async function callConfiguredTool(input) {
|
|
|
4428
4667
|
evidence_resource: `synapsor://evidence/${evidenceBundleId}`,
|
|
4429
4668
|
proposal_resource: `synapsor://proposals/${proposal.proposal_id}`,
|
|
4430
4669
|
replay_resource: `synapsor://replay/replay_${proposal.proposal_id}`,
|
|
4431
|
-
|
|
4670
|
+
approval: approvalResult.approved ? { mode: "policy", policy: approvalResult.policy } : {
|
|
4671
|
+
mode: capability.approval?.mode ?? "human",
|
|
4672
|
+
...capability.approval?.policy ? { policy: capability.approval.policy } : {},
|
|
4673
|
+
...capability.approval?.required_role ? { required_role: capability.approval.required_role } : {}
|
|
4674
|
+
},
|
|
4675
|
+
approval_required: approvalResult.proposal.state === "pending_review",
|
|
4432
4676
|
writeback: changeSet.writeback,
|
|
4433
4677
|
source_database_changed: false,
|
|
4434
4678
|
source_database_mutated: false
|
|
@@ -4454,6 +4698,9 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
4454
4698
|
const proposalId = typeof legacy.proposal_id === "string" ? legacy.proposal_id : "wrp_unknown";
|
|
4455
4699
|
const targetType = typeof target2?.type === "string" ? target2.type : capability?.target.table ?? "object";
|
|
4456
4700
|
const targetId = target2?.id !== void 0 ? String(target2.id) : "unknown";
|
|
4701
|
+
const approval = isRecord4(legacy.approval) ? legacy.approval : void 0;
|
|
4702
|
+
const state = typeof legacy.status === "string" ? legacy.status : "review_required";
|
|
4703
|
+
const approvalRequired = legacy.approval_required !== false;
|
|
4457
4704
|
const executor = writebackExecutorName(legacy.writeback);
|
|
4458
4705
|
const writebackMode = executor === "read_only" || executor === "none" ? "proposal_only" : executor && executor !== "sql_update" && executor !== "trusted_worker_required" ? "app_handler" : "direct_update";
|
|
4459
4706
|
return {
|
|
@@ -4464,15 +4711,16 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
4464
4711
|
data: null,
|
|
4465
4712
|
proposal: {
|
|
4466
4713
|
id: proposalId,
|
|
4467
|
-
state
|
|
4714
|
+
state,
|
|
4468
4715
|
target: `${targetType}:${targetId}`,
|
|
4469
4716
|
diff: isRecord4(legacy.diff) ? legacy.diff : {},
|
|
4470
|
-
approval_required:
|
|
4717
|
+
approval_required: approvalRequired,
|
|
4718
|
+
...approval ? { approval } : {},
|
|
4471
4719
|
writeback: {
|
|
4472
4720
|
mode: writebackMode,
|
|
4473
4721
|
applied: false
|
|
4474
4722
|
},
|
|
4475
|
-
next: "A human must approve outside this model-facing tool surface; nothing is committed yet."
|
|
4723
|
+
next: approvalRequired ? "A human must approve outside this model-facing tool surface; nothing is committed yet." : "The proposal is approved outside the model-facing tool surface; trusted writeback/apply is still separate."
|
|
4476
4724
|
},
|
|
4477
4725
|
error: null,
|
|
4478
4726
|
evidence: evidenceBundleId ? evidenceHandle(evidenceBundleId) : null,
|
|
@@ -4548,6 +4796,68 @@ function assertProposalWritebackResolvable(config, capability) {
|
|
|
4548
4796
|
throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but executor ${executorName} is not an app-owned handler.`);
|
|
4549
4797
|
}
|
|
4550
4798
|
}
|
|
4799
|
+
function assertApprovalPolicyResolvable(config, capability) {
|
|
4800
|
+
if (capability.kind !== "proposal" || capability.approval?.mode !== "policy") return;
|
|
4801
|
+
const policyName = capability.approval.policy;
|
|
4802
|
+
if (!policyName) {
|
|
4803
|
+
throw new McpRuntimeError("APPROVAL_POLICY_UNRESOLVED", `capability ${capability.name} uses policy approval but does not name approval.policy.`);
|
|
4804
|
+
}
|
|
4805
|
+
const policy = approvalPolicyByName(config, policyName);
|
|
4806
|
+
if (!policy) {
|
|
4807
|
+
throw new McpRuntimeError("APPROVAL_POLICY_UNRESOLVED", `capability ${capability.name} references missing approval policy ${policyName}.`);
|
|
4808
|
+
}
|
|
4809
|
+
}
|
|
4810
|
+
function maybeAutoApproveProposal(input) {
|
|
4811
|
+
if (input.config.mode !== "review") return { proposal: input.proposal, approved: false };
|
|
4812
|
+
if (input.config.approvals?.disable_auto_approval === true) return { proposal: input.proposal, approved: false };
|
|
4813
|
+
if (input.capability.approval?.mode !== "policy" || !input.capability.approval.policy) return { proposal: input.proposal, approved: false };
|
|
4814
|
+
if (input.proposal.state === "approved") return { proposal: input.proposal, approved: true, policy: input.capability.approval.policy };
|
|
4815
|
+
if (input.proposal.state !== "pending_review") return { proposal: input.proposal, approved: false };
|
|
4816
|
+
const policyName = input.capability.approval.policy;
|
|
4817
|
+
const policy = approvalPolicyByName(input.config, policyName);
|
|
4818
|
+
if (!policy) throw new McpRuntimeError("APPROVAL_POLICY_UNRESOLVED", `capability ${input.capability.name} references missing approval policy ${policyName}.`);
|
|
4819
|
+
const evaluation = evaluateApprovalPolicy(input.capability, policy, input.patch);
|
|
4820
|
+
if (!evaluation.qualifies) return { proposal: input.proposal, approved: false, policy: policyName };
|
|
4821
|
+
const updated = input.store.approveProposal(input.proposal.proposal_id, {
|
|
4822
|
+
approver: `policy:${policyName}`,
|
|
4823
|
+
proposal_hash: input.proposal.proposal_hash,
|
|
4824
|
+
proposal_version: input.proposal.proposal_version,
|
|
4825
|
+
reason: `auto-approved by policy ${policyName}: ${evaluation.reason}`
|
|
4826
|
+
});
|
|
4827
|
+
return { proposal: updated, approved: true, policy: policyName };
|
|
4828
|
+
}
|
|
4829
|
+
function approvalPolicyByName(config, policyName) {
|
|
4830
|
+
return (config.policies ?? []).find((policy) => policy.kind === "approval" && policy.name === policyName);
|
|
4831
|
+
}
|
|
4832
|
+
function evaluateApprovalPolicy(capability, policy, patch) {
|
|
4833
|
+
const ruleByField = /* @__PURE__ */ new Map();
|
|
4834
|
+
for (const rule of policy.rules ?? []) {
|
|
4835
|
+
const field = typeof rule.field === "string" ? rule.field : void 0;
|
|
4836
|
+
const max = typeof rule.max === "number" && Number.isInteger(rule.max) ? rule.max : void 0;
|
|
4837
|
+
if (field && max !== void 0) ruleByField.set(field, max);
|
|
4838
|
+
}
|
|
4839
|
+
const numericFields = Object.keys(patch).filter((field) => isNumericRuntimeProposalField(capability, field));
|
|
4840
|
+
if (numericFields.length === 0) return { qualifies: false, reason: "no numeric patch fields covered by policy" };
|
|
4841
|
+
const reasons = [];
|
|
4842
|
+
for (const field of numericFields) {
|
|
4843
|
+
const max = ruleByField.get(field);
|
|
4844
|
+
const value = patch[field];
|
|
4845
|
+
if (max === void 0) return { qualifies: false, reason: `${field} has no matching policy rule` };
|
|
4846
|
+
if (typeof value !== "number" || !Number.isInteger(value)) return { qualifies: false, reason: `${field} is not an integer` };
|
|
4847
|
+
if (value > max) return { qualifies: false, reason: `${field} ${value} > ${max}` };
|
|
4848
|
+
reasons.push(`${field} ${value} <= ${max}`);
|
|
4849
|
+
}
|
|
4850
|
+
return { qualifies: true, reason: reasons.join(", ") };
|
|
4851
|
+
}
|
|
4852
|
+
function isNumericRuntimeProposalField(capability, field) {
|
|
4853
|
+
const proposal = {
|
|
4854
|
+
action: capability.name,
|
|
4855
|
+
allowed_fields: capability.allowed_columns ?? Object.keys(capability.patch ?? {}),
|
|
4856
|
+
patch: capability.patch ?? {},
|
|
4857
|
+
...capability.numeric_bounds ? { numeric_bounds: capability.numeric_bounds } : {}
|
|
4858
|
+
};
|
|
4859
|
+
return isNumericProposalField(proposal, capability.args, field);
|
|
4860
|
+
}
|
|
4551
4861
|
function evidenceHandle(bundleId) {
|
|
4552
4862
|
return {
|
|
4553
4863
|
bundle_id: bundleId,
|
|
@@ -4653,6 +4963,8 @@ function buildChangeSet(input) {
|
|
|
4653
4963
|
},
|
|
4654
4964
|
approval: {
|
|
4655
4965
|
status: "pending",
|
|
4966
|
+
...input.capability.approval?.mode ? { mode: input.capability.approval.mode } : {},
|
|
4967
|
+
...input.capability.approval?.policy ? { policy: input.capability.approval.policy } : {},
|
|
4656
4968
|
required_role: input.capability.approval?.required_role
|
|
4657
4969
|
},
|
|
4658
4970
|
writeback: {
|
|
@@ -6693,6 +7005,7 @@ function compileAgentDslWithWarnings(source) {
|
|
|
6693
7005
|
...context.tenantBinding ? { tenant_binding: context.tenantBinding } : {},
|
|
6694
7006
|
...context.principalBinding ? { principal_binding: context.principalBinding } : {}
|
|
6695
7007
|
}));
|
|
7008
|
+
const policies = [];
|
|
6696
7009
|
const capabilities = ast.capabilities.map((capability) => {
|
|
6697
7010
|
const spec = {
|
|
6698
7011
|
name: capability.name,
|
|
@@ -6716,6 +7029,15 @@ function compileAgentDslWithWarnings(source) {
|
|
|
6716
7029
|
...capability.maxRows ? { max_rows: capability.maxRows } : {}
|
|
6717
7030
|
};
|
|
6718
7031
|
if (capability.kind === "proposal" && capability.proposal) {
|
|
7032
|
+
const autoApprovalPolicyName = capability.proposal.autoApprovalRules?.length ? autoApprovalPolicyNameForCapability(capability.name) : void 0;
|
|
7033
|
+
if (autoApprovalPolicyName) {
|
|
7034
|
+
policies.push({
|
|
7035
|
+
name: autoApprovalPolicyName,
|
|
7036
|
+
kind: "approval",
|
|
7037
|
+
mode: "green",
|
|
7038
|
+
rules: capability.proposal.autoApprovalRules?.map((rule) => ({ field: rule.field, max: rule.max }))
|
|
7039
|
+
});
|
|
7040
|
+
}
|
|
6719
7041
|
spec.proposal = {
|
|
6720
7042
|
action: capability.proposal.action,
|
|
6721
7043
|
allowed_fields: capability.proposal.allowedFields,
|
|
@@ -6723,7 +7045,7 @@ function compileAgentDslWithWarnings(source) {
|
|
|
6723
7045
|
...capability.proposal.numericBounds ? { numeric_bounds: capability.proposal.numericBounds } : {},
|
|
6724
7046
|
...capability.proposal.transitionGuards ? { transition_guards: capability.proposal.transitionGuards } : {},
|
|
6725
7047
|
conflict_guard: capability.conflictKey ? { column: capability.conflictKey } : { weak_guard_ack: true },
|
|
6726
|
-
approval: { mode: "human", required_role: capability.proposal.approvalRole ?? "local_reviewer" },
|
|
7048
|
+
approval: autoApprovalPolicyName ? { mode: "policy", required_role: capability.proposal.approvalRole ?? "local_reviewer", policy: autoApprovalPolicyName } : { mode: "human", required_role: capability.proposal.approvalRole ?? "local_reviewer" },
|
|
6727
7049
|
writeback: {
|
|
6728
7050
|
mode: capability.proposal.writebackMode ?? "direct_sql",
|
|
6729
7051
|
...capability.proposal.executor ? { executor: capability.proposal.executor } : {}
|
|
@@ -6745,7 +7067,8 @@ function compileAgentDslWithWarnings(source) {
|
|
|
6745
7067
|
kind: "SynapsorContract",
|
|
6746
7068
|
contexts,
|
|
6747
7069
|
capabilities,
|
|
6748
|
-
...workflows.length ? { workflows } : {}
|
|
7070
|
+
...workflows.length ? { workflows } : {},
|
|
7071
|
+
...policies.length ? { policies } : {}
|
|
6749
7072
|
};
|
|
6750
7073
|
assertValidContract(contract);
|
|
6751
7074
|
return { contract: normalizeContract(contract), warnings: collectDslWarnings(ast) };
|
|
@@ -6972,6 +7295,12 @@ function parseCapabilityBlock(block) {
|
|
|
6972
7295
|
capability.proposal.approvalRole = approval[1];
|
|
6973
7296
|
continue;
|
|
6974
7297
|
}
|
|
7298
|
+
const autoApproval = item.text.match(/^AUTO\s+APPROVE\s+WHEN\s+(.+)$/i);
|
|
7299
|
+
if (autoApproval?.[1]) {
|
|
7300
|
+
ensureProposal(capability, item);
|
|
7301
|
+
parseAutoApprovalClause(capability, autoApproval[1], item.line);
|
|
7302
|
+
continue;
|
|
7303
|
+
}
|
|
6975
7304
|
const writeback2 = item.text.match(/^WRITEBACK\s+(DIRECT\s+SQL|APP\s+HANDLER|CLOUD\s+WORKER|NONE)(?:\s+EXECUTOR\s+([A-Za-z_][A-Za-z0-9_.-]*))?$/i);
|
|
6976
7305
|
if (writeback2?.[1]) {
|
|
6977
7306
|
ensureProposal(capability, item);
|
|
@@ -7032,6 +7361,52 @@ function collectDslWarnings(ast) {
|
|
|
7032
7361
|
}
|
|
7033
7362
|
return warnings;
|
|
7034
7363
|
}
|
|
7364
|
+
function parseAutoApprovalClause(capability, rawCondition, line) {
|
|
7365
|
+
if (!capability.proposal.approvalRole) {
|
|
7366
|
+
throw dslError(line, 1, "AUTO_APPROVE_APPROVAL_ROLE_REQUIRED", "AUTO APPROVE WHEN requires an explicit APPROVAL ROLE before it");
|
|
7367
|
+
}
|
|
7368
|
+
const condition = rawCondition.trim();
|
|
7369
|
+
const match = condition.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*(<=)\s*(.+)$/);
|
|
7370
|
+
if (!match?.[1] || !match[3]) {
|
|
7371
|
+
throw dslError(line, 1, "AUTO_APPROVE_UNSUPPORTED", "AUTO APPROVE WHEN supports only: <field> <= <integer>");
|
|
7372
|
+
}
|
|
7373
|
+
const field = match[1];
|
|
7374
|
+
const rawMax = match[3].trim();
|
|
7375
|
+
if (!/^\d+$/.test(rawMax)) {
|
|
7376
|
+
throw dslError(line, 1, "AUTO_APPROVE_MAX_INVALID", "AUTO APPROVE WHEN max must be a non-negative integer");
|
|
7377
|
+
}
|
|
7378
|
+
const max = Number(rawMax);
|
|
7379
|
+
if (!Number.isSafeInteger(max)) {
|
|
7380
|
+
throw dslError(line, 1, "AUTO_APPROVE_MAX_INVALID", "AUTO APPROVE WHEN max must be a safe non-negative integer");
|
|
7381
|
+
}
|
|
7382
|
+
if (!Object.prototype.hasOwnProperty.call(capability.proposal.patch, field)) {
|
|
7383
|
+
throw dslError(line, 1, "AUTO_APPROVE_FIELD_NOT_PATCHED", `AUTO APPROVE WHEN field ${field} must be in the PATCH list`);
|
|
7384
|
+
}
|
|
7385
|
+
if (!isNumericDslProposalField(capability, field)) {
|
|
7386
|
+
throw dslError(line, 1, "AUTO_APPROVE_FIELD_NOT_NUMERIC", `AUTO APPROVE WHEN field ${field} must be numeric by BOUND, NUMBER arg patch, or integer literal patch`);
|
|
7387
|
+
}
|
|
7388
|
+
const boundMax = capability.proposal.numericBounds?.[field]?.maximum;
|
|
7389
|
+
if (boundMax !== void 0 && max > boundMax) {
|
|
7390
|
+
throw dslError(line, 1, "AUTO_APPROVE_MAX_EXCEEDS_BOUND", `AUTO APPROVE WHEN max for ${field} must be <= BOUND maximum ${boundMax}`);
|
|
7391
|
+
}
|
|
7392
|
+
capability.proposal.autoApprovalRules ??= [];
|
|
7393
|
+
if (capability.proposal.autoApprovalRules.some((rule) => rule.field === field)) {
|
|
7394
|
+
throw dslError(line, 1, "AUTO_APPROVE_DUPLICATE_FIELD", `AUTO APPROVE WHEN already defines a rule for ${field}`);
|
|
7395
|
+
}
|
|
7396
|
+
capability.proposal.autoApprovalRules.push({ field, max, line });
|
|
7397
|
+
}
|
|
7398
|
+
function isNumericDslProposalField(capability, field) {
|
|
7399
|
+
if (capability.proposal.numericBounds?.[field] !== void 0)
|
|
7400
|
+
return true;
|
|
7401
|
+
const patch = capability.proposal.patch[field];
|
|
7402
|
+
if (!patch)
|
|
7403
|
+
return false;
|
|
7404
|
+
if (typeof patch.fixed === "number" && Number.isInteger(patch.fixed))
|
|
7405
|
+
return true;
|
|
7406
|
+
if (patch.from_arg && capability.args[patch.from_arg]?.type === "number")
|
|
7407
|
+
return true;
|
|
7408
|
+
return false;
|
|
7409
|
+
}
|
|
7035
7410
|
function parseWorkflowBlock(block) {
|
|
7036
7411
|
const workflow = { name: block.name, context: "", allowedCapabilities: [] };
|
|
7037
7412
|
for (const item of block.body) {
|
|
@@ -7191,6 +7566,9 @@ function normalizeWritebackMode(mode) {
|
|
|
7191
7566
|
return "cloud_worker";
|
|
7192
7567
|
return "none";
|
|
7193
7568
|
}
|
|
7569
|
+
function autoApprovalPolicyNameForCapability(name) {
|
|
7570
|
+
return `${name.replace(/[^A-Za-z0-9_]/g, "_")}_auto_approval`;
|
|
7571
|
+
}
|
|
7194
7572
|
function parseList(value) {
|
|
7195
7573
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
7196
7574
|
}
|
|
@@ -8379,6 +8757,11 @@ async function main(argv) {
|
|
|
8379
8757
|
usage([]);
|
|
8380
8758
|
return 0;
|
|
8381
8759
|
}
|
|
8760
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
8761
|
+
process2.stdout.write(`${await runnerPackageVersion()}
|
|
8762
|
+
`);
|
|
8763
|
+
return 0;
|
|
8764
|
+
}
|
|
8382
8765
|
if (!isKnownTopLevelCommand(command)) {
|
|
8383
8766
|
process2.stderr.write(`Unknown command: ${cliCommandName()} ${command}
|
|
8384
8767
|
|
|
@@ -9889,6 +10272,32 @@ function proposalWritebackResolutionDoctorCheck(config, capability) {
|
|
|
9889
10272
|
};
|
|
9890
10273
|
}
|
|
9891
10274
|
}
|
|
10275
|
+
function proposalApprovalPolicyResolutionDoctorCheck(config, capability) {
|
|
10276
|
+
if (capability.approval?.mode !== "policy") {
|
|
10277
|
+
return {
|
|
10278
|
+
name: `capability:${capability.name}:approval-policy-resolution`,
|
|
10279
|
+
ok: true,
|
|
10280
|
+
level: "pass",
|
|
10281
|
+
message: "Capability does not use policy auto-approval."
|
|
10282
|
+
};
|
|
10283
|
+
}
|
|
10284
|
+
try {
|
|
10285
|
+
assertApprovalPolicyResolvable(config, capability);
|
|
10286
|
+
return {
|
|
10287
|
+
name: `capability:${capability.name}:approval-policy-resolution`,
|
|
10288
|
+
ok: true,
|
|
10289
|
+
level: "pass",
|
|
10290
|
+
message: `Approval policy ${capability.approval.policy} resolves from the reviewed contract/config.`
|
|
10291
|
+
};
|
|
10292
|
+
} catch (error) {
|
|
10293
|
+
return {
|
|
10294
|
+
name: `capability:${capability.name}:approval-policy-resolution`,
|
|
10295
|
+
ok: false,
|
|
10296
|
+
level: "fail",
|
|
10297
|
+
message: error instanceof Error ? error.message : String(error)
|
|
10298
|
+
};
|
|
10299
|
+
}
|
|
10300
|
+
}
|
|
9892
10301
|
async function httpHandlerReachabilityCheck(executorName, rawUrl, timeoutMs) {
|
|
9893
10302
|
try {
|
|
9894
10303
|
const url = new URL(rawUrl);
|
|
@@ -10396,6 +10805,7 @@ async function localDoctor(args) {
|
|
|
10396
10805
|
if (parsed.mode === "review") {
|
|
10397
10806
|
for (const capability of (parsed.capabilities ?? []).filter((item) => item.kind === "proposal")) {
|
|
10398
10807
|
checks.push(proposalWritebackResolutionDoctorCheck(parsed, capability));
|
|
10808
|
+
checks.push(proposalApprovalPolicyResolutionDoctorCheck(parsed, capability));
|
|
10399
10809
|
}
|
|
10400
10810
|
}
|
|
10401
10811
|
for (const [sourceName, source] of Object.entries(sources)) {
|
|
@@ -11576,6 +11986,8 @@ async function cloudPush(args) {
|
|
|
11576
11986
|
process2.stdout.write(`Workflows: ${payload.summary.workflows}
|
|
11577
11987
|
`);
|
|
11578
11988
|
process2.stdout.write(`Proposal capabilities: ${payload.summary.proposal_capabilities}
|
|
11989
|
+
`);
|
|
11990
|
+
process2.stdout.write(`Approval policies: ${payload.summary.approval_policies}
|
|
11579
11991
|
`);
|
|
11580
11992
|
process2.stdout.write(`Kept-out fields: ${payload.summary.kept_out_fields}
|
|
11581
11993
|
`);
|
|
@@ -11623,12 +12035,17 @@ async function cloudPush(args) {
|
|
|
11623
12035
|
return 0;
|
|
11624
12036
|
}
|
|
11625
12037
|
function contractSummary(contract) {
|
|
12038
|
+
const keptOutFields = /* @__PURE__ */ new Set();
|
|
12039
|
+
for (const capability of contract.capabilities) {
|
|
12040
|
+
for (const field of capability.kept_out_fields ?? []) keptOutFields.add(field);
|
|
12041
|
+
}
|
|
11626
12042
|
return {
|
|
11627
12043
|
contexts: contract.contexts.length,
|
|
11628
12044
|
capabilities: contract.capabilities.length,
|
|
11629
12045
|
workflows: contract.workflows?.length ?? 0,
|
|
11630
12046
|
proposal_capabilities: contract.capabilities.filter((capability) => capability.kind === "proposal").length,
|
|
11631
|
-
|
|
12047
|
+
approval_policies: contract.policies?.filter((policy) => policy.kind === "approval").length ?? 0,
|
|
12048
|
+
kept_out_fields: keptOutFields.size
|
|
11632
12049
|
};
|
|
11633
12050
|
}
|
|
11634
12051
|
async function postCloudContractPush(input) {
|
|
@@ -12797,8 +13214,11 @@ function formatProposeResult(capabilityName, result, storePath) {
|
|
|
12797
13214
|
const proposalId = String(result.proposal_id ?? "");
|
|
12798
13215
|
const evidenceId = String(result.evidence_bundle_id ?? "");
|
|
12799
13216
|
const sourceChanged = result.source_database_changed === true || result.source_database_mutated === true;
|
|
13217
|
+
const status = String(result.status ?? "review_required");
|
|
13218
|
+
const approval = isRecord7(result.approval) ? result.approval : void 0;
|
|
13219
|
+
const autoApproved = status === "approved" && approval?.mode === "policy";
|
|
12800
13220
|
const lines = [
|
|
12801
|
-
"Proposal created.",
|
|
13221
|
+
autoApproved ? "Proposal created and policy-approved." : "Proposal created.",
|
|
12802
13222
|
"",
|
|
12803
13223
|
"Capability:",
|
|
12804
13224
|
capabilityName,
|
|
@@ -12812,9 +13232,12 @@ function formatProposeResult(capabilityName, result, storePath) {
|
|
|
12812
13232
|
"Source DB changed:",
|
|
12813
13233
|
sourceChanged ? "yes" : "no",
|
|
12814
13234
|
"",
|
|
13235
|
+
"Approval:",
|
|
13236
|
+
autoApproved ? `approved by policy ${String(approval?.policy ?? "")}` : "required outside MCP",
|
|
13237
|
+
"",
|
|
12815
13238
|
"Review:",
|
|
12816
13239
|
`${cliCommandName()} proposals show ${proposalId || "latest"} --store ${storePath}`,
|
|
12817
|
-
`${cliCommandName()} proposals approve ${proposalId || "latest"} --store ${storePath}
|
|
13240
|
+
...autoApproved ? [] : [`${cliCommandName()} proposals approve ${proposalId || "latest"} --store ${storePath}`],
|
|
12818
13241
|
`${cliCommandName()} apply ${proposalId || "latest"} --store ${storePath}`,
|
|
12819
13242
|
`${cliCommandName()} replay ${proposalId || "latest"} --store ${storePath}`,
|
|
12820
13243
|
""
|
|
@@ -13032,6 +13455,7 @@ async function toolsPreview(args) {
|
|
|
13032
13455
|
config_path: boundary.configPath,
|
|
13033
13456
|
store_path: boundary.storePath,
|
|
13034
13457
|
alias_mode: boundary.aliasMode,
|
|
13458
|
+
auto_approval: boundary.autoApprovalDisabled ? "disabled" : "enabled",
|
|
13035
13459
|
exposed_to_mcp: boundary.names,
|
|
13036
13460
|
alias_mappings: boundary.exposures,
|
|
13037
13461
|
not_exposed_to_mcp: defaultBlockedToolSurface(),
|
|
@@ -13060,6 +13484,7 @@ Run ${cliCommandName()} onboard db --from-env DATABASE_URL, or pass --config <pa
|
|
|
13060
13484
|
const runtime = createMcpRuntime(parsed, { storePath });
|
|
13061
13485
|
try {
|
|
13062
13486
|
const tools2 = runtime.listTools();
|
|
13487
|
+
const autoApprovalDisabled = parsed.approvals?.disable_auto_approval === true;
|
|
13063
13488
|
const exposures = toolNameExposures(tools2.map((tool) => tool.name), aliasMode);
|
|
13064
13489
|
const names = exposures.map((item) => item.exposedName);
|
|
13065
13490
|
const serialized = JSON.stringify(tools2);
|
|
@@ -13072,7 +13497,7 @@ Run ${cliCommandName()} onboard db --from-env DATABASE_URL, or pass --config <pa
|
|
|
13072
13497
|
{ name: "write credentials absent", ok: !/(password|secret|bearer|private[_-]?key|token)/i.test(serialized), detail: "MCP tools do not include write credentials" }
|
|
13073
13498
|
];
|
|
13074
13499
|
const ok = checks.every((check) => check.ok);
|
|
13075
|
-
return { ok, configPath, storePath, aliasMode, names, exposures, checks };
|
|
13500
|
+
return { ok, configPath, storePath, aliasMode, names, exposures, autoApprovalDisabled, checks };
|
|
13076
13501
|
} finally {
|
|
13077
13502
|
runtime.close();
|
|
13078
13503
|
}
|
|
@@ -13095,6 +13520,7 @@ function formatToolsPreview(input) {
|
|
|
13095
13520
|
`Config: ${input.configPath}`,
|
|
13096
13521
|
`Store: ${input.storePath}`,
|
|
13097
13522
|
`Alias mode: ${input.aliasMode}`,
|
|
13523
|
+
`auto-approval: ${input.autoApprovalDisabled ? "disabled" : "enabled"}`,
|
|
13098
13524
|
"",
|
|
13099
13525
|
"Exposed to MCP:",
|
|
13100
13526
|
...exposedLines,
|
|
@@ -15889,11 +16315,14 @@ function formatReceiptId(receiptId) {
|
|
|
15889
16315
|
return `rct_${String(receiptId).padStart(6, "0")}`;
|
|
15890
16316
|
}
|
|
15891
16317
|
function approvalBoundary(proposal) {
|
|
16318
|
+
const approval = proposal.change_set.approval;
|
|
16319
|
+
const policy = typeof approval.policy === "string" ? approval.policy : void 0;
|
|
16320
|
+
const policyActor = policy ? `policy:${policy}` : void 0;
|
|
15892
16321
|
if (proposal.state === "pending_review") return "required outside MCP";
|
|
15893
|
-
if (proposal.state === "approved" || proposal.state === "pending_worker") return "approved outside MCP; waiting for trusted worker
|
|
15894
|
-
if (proposal.state === "applied") return "approved outside MCP; writeback applied
|
|
15895
|
-
if (proposal.state === "conflict") return "approved outside MCP; writeback blocked by conflict guard
|
|
15896
|
-
if (proposal.state === "failed") return "approved outside MCP; writeback failed safely
|
|
16322
|
+
if (proposal.state === "approved" || proposal.state === "pending_worker") return `${policyActor ? `approved by ${policyActor}` : "approved outside MCP"}; waiting for trusted worker`;
|
|
16323
|
+
if (proposal.state === "applied") return `${policyActor ? `approved by ${policyActor}` : "approved outside MCP"}; writeback applied`;
|
|
16324
|
+
if (proposal.state === "conflict") return `${policyActor ? `approved by ${policyActor}` : "approved outside MCP"}; writeback blocked by conflict guard`;
|
|
16325
|
+
if (proposal.state === "failed") return `${policyActor ? `approved by ${policyActor}` : "approved outside MCP"}; writeback failed safely`;
|
|
15897
16326
|
if (proposal.state === "rejected") return "rejected outside MCP";
|
|
15898
16327
|
return humanStatus(proposal.state);
|
|
15899
16328
|
}
|
|
@@ -16105,6 +16534,16 @@ function cliCommandName() {
|
|
|
16105
16534
|
if (process2.env.SYNAPSOR_RUNNER_COMMAND_NAME) return process2.env.SYNAPSOR_RUNNER_COMMAND_NAME;
|
|
16106
16535
|
return "synapsor-runner";
|
|
16107
16536
|
}
|
|
16537
|
+
async function runnerPackageVersion() {
|
|
16538
|
+
if (process2.env.npm_package_version) return process2.env.npm_package_version;
|
|
16539
|
+
const packageUrl = new URL("../package.json", import.meta.url);
|
|
16540
|
+
try {
|
|
16541
|
+
const parsed = JSON.parse(await fs3.readFile(packageUrl, "utf8"));
|
|
16542
|
+
if (typeof parsed.version === "string" && parsed.version.trim()) return parsed.version.trim();
|
|
16543
|
+
} catch {
|
|
16544
|
+
}
|
|
16545
|
+
return "unknown";
|
|
16546
|
+
}
|
|
16108
16547
|
function usage(args = []) {
|
|
16109
16548
|
const [command, subcommand] = args;
|
|
16110
16549
|
const key = (command === "mcp" || command === "handler") && subcommand ? `${command} ${subcommand}` : command ?? "";
|