@synapsor/runner 1.4.122 → 1.4.123
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 +19 -3
- package/README.md +2 -2
- package/dist/runner.mjs +301 -3
- package/docs/capability-authoring.md +16 -0
- package/docs/contract-review.md +33 -0
- package/docs/release-notes.md +19 -1
- package/docs/security-boundary.md +4 -1
- package/fixtures/contracts/capability-surface-fitness.contract.json +190 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 1.4.
|
|
3
|
+
## 1.4.123 (prepared, not published)
|
|
4
|
+
|
|
5
|
+
### Advisory capability-surface fitness lint
|
|
6
|
+
|
|
7
|
+
- Adds deterministic `contract lint` advisories for generic query-like string
|
|
8
|
+
arguments, more than eight capabilities on one normalized target,
|
|
9
|
+
non-business-operation names, and structurally near-duplicate capabilities.
|
|
10
|
+
- Keeps canonical validation, compilation, MCP serving, and runtime enforcement
|
|
11
|
+
unchanged. Advisories succeed by default; explicit `--strict` or
|
|
12
|
+
`--fail-on warning` remains the opt-in CI policy gate.
|
|
13
|
+
- Adds stable structured details and surface metrics to JSON/SARIF output, plus
|
|
14
|
+
a concise text summary. No database connection, environment value, source
|
|
15
|
+
row, or probabilistic classifier is involved.
|
|
16
|
+
- Stages only `@synapsor/runner@1.4.123`; `@synapsor/spec@1.4.2`,
|
|
17
|
+
`@synapsor/dsl@1.4.3`, and `@synapsor/cli@0.1.0-beta.1` are unchanged.
|
|
18
|
+
|
|
19
|
+
## 1.4.122 (2026-07-16)
|
|
4
20
|
|
|
5
21
|
### Trusted principal row scope and Cloud-linked governance
|
|
6
22
|
|
|
@@ -23,8 +39,8 @@
|
|
|
23
39
|
Runner-token fallback.
|
|
24
40
|
- Introduces the separately packable `@synapsor/cli@0.1.0-beta.1` Cloud client;
|
|
25
41
|
`synapsor-runner` remains the local MCP/database enforcement boundary.
|
|
26
|
-
-
|
|
27
|
-
`@synapsor/runner@1.4.122`.
|
|
42
|
+
- Published `@synapsor/spec@1.4.2`, `@synapsor/dsl@1.4.3`, and
|
|
43
|
+
`@synapsor/runner@1.4.122`.
|
|
28
44
|
|
|
29
45
|
## 1.4.121 (prepared, not published)
|
|
30
46
|
|
package/README.md
CHANGED
|
@@ -166,8 +166,8 @@ compensation.
|
|
|
166
166
|
## Review And Prove Your Contract
|
|
167
167
|
|
|
168
168
|
Before serving a contract, use `contract explain` for a reviewer-readable
|
|
169
|
-
boundary, `contract lint --strict` for deterministic CI checks,
|
|
170
|
-
test` for adopter-owned allow/deny/redaction cases. The built-in language
|
|
169
|
+
boundary, `contract lint --strict` for deterministic surface-fitness CI checks,
|
|
170
|
+
and `contract test` for adopter-owned allow/deny/redaction cases. The built-in language
|
|
171
171
|
server supplies diagnostics, completion, hover, and formatting for
|
|
172
172
|
`.synapsor.sql` and legacy `.synapsor` files. See [Contract
|
|
173
173
|
Review](docs/contract-review.md) and [Contract
|
package/dist/runner.mjs
CHANGED
|
@@ -41376,6 +41376,299 @@ function isRecord7(value) {
|
|
|
41376
41376
|
// apps/runner/src/contract-tools.ts
|
|
41377
41377
|
import fs5 from "node:fs/promises";
|
|
41378
41378
|
import path5 from "node:path";
|
|
41379
|
+
|
|
41380
|
+
// apps/runner/src/capability-surface-lint.ts
|
|
41381
|
+
var CAPABILITY_SURFACE_DENSITY_REVIEW_THRESHOLD = 8;
|
|
41382
|
+
var GENERIC_ARGUMENT_NAMES = /* @__PURE__ */ new Set(["filter", "predicate", "query", "sql", "where"]);
|
|
41383
|
+
var ACTION_PREFIXES = /* @__PURE__ */ new Set([
|
|
41384
|
+
"add",
|
|
41385
|
+
"answer",
|
|
41386
|
+
"archive",
|
|
41387
|
+
"assign",
|
|
41388
|
+
"cancel",
|
|
41389
|
+
"change",
|
|
41390
|
+
"close",
|
|
41391
|
+
"count",
|
|
41392
|
+
"create",
|
|
41393
|
+
"delete",
|
|
41394
|
+
"export",
|
|
41395
|
+
"find",
|
|
41396
|
+
"get",
|
|
41397
|
+
"grant",
|
|
41398
|
+
"inspect",
|
|
41399
|
+
"list",
|
|
41400
|
+
"lookup",
|
|
41401
|
+
"open",
|
|
41402
|
+
"propose",
|
|
41403
|
+
"read",
|
|
41404
|
+
"refund",
|
|
41405
|
+
"remove",
|
|
41406
|
+
"request",
|
|
41407
|
+
"resolve",
|
|
41408
|
+
"restore",
|
|
41409
|
+
"revert",
|
|
41410
|
+
"search",
|
|
41411
|
+
"set",
|
|
41412
|
+
"sum",
|
|
41413
|
+
"update",
|
|
41414
|
+
"verify",
|
|
41415
|
+
"waive",
|
|
41416
|
+
"write"
|
|
41417
|
+
]);
|
|
41418
|
+
var ACTION_SUFFIXES = /* @__PURE__ */ new Set(["average", "count", "lookup", "review", "search", "summary", "total"]);
|
|
41419
|
+
var GENERIC_OPERATION_WORDS = /* @__PURE__ */ new Set([
|
|
41420
|
+
"create",
|
|
41421
|
+
"data",
|
|
41422
|
+
"database",
|
|
41423
|
+
"db",
|
|
41424
|
+
"delete",
|
|
41425
|
+
"execute",
|
|
41426
|
+
"get",
|
|
41427
|
+
"item",
|
|
41428
|
+
"manage",
|
|
41429
|
+
"mutate",
|
|
41430
|
+
"object",
|
|
41431
|
+
"process",
|
|
41432
|
+
"query",
|
|
41433
|
+
"read",
|
|
41434
|
+
"record",
|
|
41435
|
+
"resource",
|
|
41436
|
+
"row",
|
|
41437
|
+
"run",
|
|
41438
|
+
"set",
|
|
41439
|
+
"sql",
|
|
41440
|
+
"table",
|
|
41441
|
+
"thing",
|
|
41442
|
+
"update",
|
|
41443
|
+
"write"
|
|
41444
|
+
]);
|
|
41445
|
+
function analyzeCapabilitySurface(contract) {
|
|
41446
|
+
const resources = new Map((contract.resources ?? []).map((resource) => [resource.name, resource]));
|
|
41447
|
+
const indexed = contract.capabilities.map((capability) => {
|
|
41448
|
+
const resource = capability.subject.resource ? resources.get(capability.subject.resource) : void 0;
|
|
41449
|
+
const source = capability.source ?? resource?.engine ?? "unresolved-source";
|
|
41450
|
+
const schema = capability.subject.schema ?? resource?.schema ?? "unresolved-schema";
|
|
41451
|
+
const table = capability.subject.table ?? resource?.table ?? capability.subject.resource ?? "unresolved-object";
|
|
41452
|
+
return {
|
|
41453
|
+
capability,
|
|
41454
|
+
targetKey: `${source}\0${schema}\0${table}`,
|
|
41455
|
+
targetLabel: `${source}:${schema}.${table}`
|
|
41456
|
+
};
|
|
41457
|
+
});
|
|
41458
|
+
const findings = [];
|
|
41459
|
+
for (const entry of indexed) {
|
|
41460
|
+
collectGenericArgumentFindings(findings, entry);
|
|
41461
|
+
collectOperationNamingFinding(findings, entry);
|
|
41462
|
+
}
|
|
41463
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
41464
|
+
for (const entry of indexed) grouped.set(entry.targetKey, [...grouped.get(entry.targetKey) ?? [], entry]);
|
|
41465
|
+
const targets = [...grouped.values()].map((entries) => {
|
|
41466
|
+
const sortedEntries = [...entries].sort((left, right) => left.capability.name.localeCompare(right.capability.name));
|
|
41467
|
+
const capabilityNames = sortedEntries.map((entry) => entry.capability.name);
|
|
41468
|
+
const densityWarning = entries.length > CAPABILITY_SURFACE_DENSITY_REVIEW_THRESHOLD;
|
|
41469
|
+
if (densityWarning) {
|
|
41470
|
+
findings.push({
|
|
41471
|
+
code: "SURFACE_TARGET_DENSITY",
|
|
41472
|
+
severity: "warning",
|
|
41473
|
+
path: "$.capabilities",
|
|
41474
|
+
message: `${entries[0].targetLabel} exposes ${entries.length} capabilities, above the advisory review threshold of ${CAPABILITY_SURFACE_DENSITY_REVIEW_THRESHOLD}. Review whether every operation belongs on the same model-facing surface or should be consolidated or assigned to a narrower agent.`,
|
|
41475
|
+
details: {
|
|
41476
|
+
target: entries[0].targetLabel,
|
|
41477
|
+
capability_count: entries.length,
|
|
41478
|
+
review_threshold: CAPABILITY_SURFACE_DENSITY_REVIEW_THRESHOLD,
|
|
41479
|
+
capabilities: capabilityNames
|
|
41480
|
+
}
|
|
41481
|
+
});
|
|
41482
|
+
}
|
|
41483
|
+
collectNearDuplicateFindings(findings, sortedEntries);
|
|
41484
|
+
return {
|
|
41485
|
+
target: entries[0].targetLabel,
|
|
41486
|
+
capability_count: entries.length,
|
|
41487
|
+
capabilities: capabilityNames,
|
|
41488
|
+
density_warning: densityWarning
|
|
41489
|
+
};
|
|
41490
|
+
}).sort((left, right) => left.target.localeCompare(right.target));
|
|
41491
|
+
findings.sort((left, right) => left.code.localeCompare(right.code) || left.path.localeCompare(right.path) || left.message.localeCompare(right.message));
|
|
41492
|
+
return {
|
|
41493
|
+
findings,
|
|
41494
|
+
summary: {
|
|
41495
|
+
total_capabilities: indexed.length,
|
|
41496
|
+
target_count: targets.length,
|
|
41497
|
+
density_review_threshold: CAPABILITY_SURFACE_DENSITY_REVIEW_THRESHOLD,
|
|
41498
|
+
targets
|
|
41499
|
+
}
|
|
41500
|
+
};
|
|
41501
|
+
}
|
|
41502
|
+
function collectGenericArgumentFindings(findings, entry) {
|
|
41503
|
+
const visit = (arg, argName, argPath) => {
|
|
41504
|
+
if (arg.type === "object_array") {
|
|
41505
|
+
for (const [fieldName, field] of Object.entries(arg.fields).sort(([left], [right]) => left.localeCompare(right))) {
|
|
41506
|
+
visit(field, fieldName, `${argPath}.fields.${fieldName}`);
|
|
41507
|
+
}
|
|
41508
|
+
return;
|
|
41509
|
+
}
|
|
41510
|
+
const normalizedName = normalizeIdentifier(argName);
|
|
41511
|
+
if (arg.type !== "string" || (arg.enum?.length ?? 0) > 0 || !GENERIC_ARGUMENT_NAMES.has(normalizedName)) return;
|
|
41512
|
+
findings.push({
|
|
41513
|
+
code: "SURFACE_GENERIC_ARGUMENT",
|
|
41514
|
+
severity: "warning",
|
|
41515
|
+
path: argPath,
|
|
41516
|
+
message: `${entry.capability.name} exposes un-enumerated string argument ${argName}, a generic query/predicate-style name. Runner does not turn it into SQL, but reviewers should confirm this remains a named business operation instead of an escape-hatch surface.`,
|
|
41517
|
+
details: { capability: entry.capability.name, argument: argName }
|
|
41518
|
+
});
|
|
41519
|
+
};
|
|
41520
|
+
for (const [argName, arg] of Object.entries(entry.capability.args).sort(([left], [right]) => left.localeCompare(right))) {
|
|
41521
|
+
visit(arg, argName, `${capabilityPath(entry.capability.name)}.args.${argName}`);
|
|
41522
|
+
}
|
|
41523
|
+
}
|
|
41524
|
+
function collectOperationNamingFinding(findings, entry) {
|
|
41525
|
+
const operation = entry.capability.name.split(".").at(-1) ?? entry.capability.name;
|
|
41526
|
+
const tokens = tokenizeIdentifier(operation);
|
|
41527
|
+
const genericOnly = tokens.length > 0 && tokens.every((token) => GENERIC_OPERATION_WORDS.has(token));
|
|
41528
|
+
const actionOriented = tokens.length >= 2 && (ACTION_PREFIXES.has(tokens[0]) || ACTION_SUFFIXES.has(tokens.at(-1)));
|
|
41529
|
+
if (tokens.length >= 2 && actionOriented && !genericOnly) return;
|
|
41530
|
+
findings.push({
|
|
41531
|
+
code: "SURFACE_OPERATION_NAMING",
|
|
41532
|
+
severity: "warning",
|
|
41533
|
+
path: `${capabilityPath(entry.capability.name)}.name`,
|
|
41534
|
+
message: `${entry.capability.name} does not read as a high-confidence named business operation. Use a reviewer-recognizable operation such as inspect_invoice or propose_plan_credit; this is a naming heuristic, not a runtime enforcement failure.`,
|
|
41535
|
+
details: { capability: entry.capability.name, operation, tokens }
|
|
41536
|
+
});
|
|
41537
|
+
}
|
|
41538
|
+
function collectNearDuplicateFindings(findings, entries) {
|
|
41539
|
+
for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) {
|
|
41540
|
+
for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) {
|
|
41541
|
+
const left = entries[leftIndex];
|
|
41542
|
+
const right = entries[rightIndex];
|
|
41543
|
+
if (left.capability.kind !== right.capability.kind || capabilityShape(left.capability) !== capabilityShape(right.capability)) continue;
|
|
41544
|
+
const relationship = compareArgumentSurfaces(left.capability.args, right.capability.args);
|
|
41545
|
+
if (!relationship) continue;
|
|
41546
|
+
const differences = relationship.differences.length > 0 ? relationship.differences : ["identical model-visible arguments"];
|
|
41547
|
+
findings.push({
|
|
41548
|
+
code: "SURFACE_NEAR_DUPLICATE",
|
|
41549
|
+
severity: "warning",
|
|
41550
|
+
path: "$.capabilities",
|
|
41551
|
+
message: `${left.capability.name} and ${right.capability.name} have the same target, kind, reviewed fields, targeting, and write/approval shape, with only this argument-surface difference: ${differences.join("; ")}. Review whether both operations are necessary or one is a loosened duplicate.`,
|
|
41552
|
+
details: {
|
|
41553
|
+
target: left.targetLabel,
|
|
41554
|
+
capabilities: [left.capability.name, right.capability.name],
|
|
41555
|
+
relationship: relationship.relationship,
|
|
41556
|
+
differences
|
|
41557
|
+
}
|
|
41558
|
+
});
|
|
41559
|
+
}
|
|
41560
|
+
}
|
|
41561
|
+
}
|
|
41562
|
+
function capabilityShape(capability) {
|
|
41563
|
+
const { name: _name, description: _description, returns_hint: _returnsHint, args: _args, ...shape } = capability;
|
|
41564
|
+
return stableString(shape);
|
|
41565
|
+
}
|
|
41566
|
+
function compareArgumentSurfaces(left, right) {
|
|
41567
|
+
if (stableString(stripArgumentDescriptions(left)) === stableString(stripArgumentDescriptions(right))) {
|
|
41568
|
+
return { relationship: "identical", differences: [] };
|
|
41569
|
+
}
|
|
41570
|
+
const leftLooser = argumentMapNoStricter(left, right);
|
|
41571
|
+
const rightLooser = argumentMapNoStricter(right, left);
|
|
41572
|
+
if (leftLooser && !rightLooser) return { relationship: "left_is_looser", differences: describeRelaxations(left, right) };
|
|
41573
|
+
if (rightLooser && !leftLooser) return { relationship: "right_is_looser", differences: describeRelaxations(right, left) };
|
|
41574
|
+
return void 0;
|
|
41575
|
+
}
|
|
41576
|
+
function argumentMapNoStricter(looser, stricter) {
|
|
41577
|
+
for (const [name, strictArg] of Object.entries(stricter)) {
|
|
41578
|
+
const looseArg = looser[name];
|
|
41579
|
+
if (!looseArg || !argumentNoStricter(looseArg, strictArg)) return false;
|
|
41580
|
+
}
|
|
41581
|
+
return Object.entries(looser).filter(([name]) => !(name in stricter)).every(([, arg]) => arg.required !== true);
|
|
41582
|
+
}
|
|
41583
|
+
function argumentNoStricter(looser, stricter) {
|
|
41584
|
+
if (looser.type !== stricter.type) return false;
|
|
41585
|
+
if (looser.required === true && stricter.required !== true) return false;
|
|
41586
|
+
if (looser.type === "object_array" && stricter.type === "object_array") {
|
|
41587
|
+
if (!sameExtensions(looser, stricter)) return false;
|
|
41588
|
+
if (looser.max_items < stricter.max_items) return false;
|
|
41589
|
+
return argumentMapNoStricter(looser.fields, stricter.fields);
|
|
41590
|
+
}
|
|
41591
|
+
if (looser.type === "object_array" || stricter.type === "object_array") return false;
|
|
41592
|
+
if (!sameExtensions(looser, stricter)) return false;
|
|
41593
|
+
if ((looser.max_length ?? Number.POSITIVE_INFINITY) < (stricter.max_length ?? Number.POSITIVE_INFINITY)) return false;
|
|
41594
|
+
if ((looser.minimum ?? Number.NEGATIVE_INFINITY) > (stricter.minimum ?? Number.NEGATIVE_INFINITY)) return false;
|
|
41595
|
+
if ((looser.maximum ?? Number.POSITIVE_INFINITY) < (stricter.maximum ?? Number.POSITIVE_INFINITY)) return false;
|
|
41596
|
+
return enumContains(looser.enum, stricter.enum);
|
|
41597
|
+
}
|
|
41598
|
+
function enumContains(looser, stricter) {
|
|
41599
|
+
if (!looser?.length) return true;
|
|
41600
|
+
if (!stricter?.length) return false;
|
|
41601
|
+
const looseValues = new Set(looser.map((value) => stableString(value)));
|
|
41602
|
+
return stricter.every((value) => looseValues.has(stableString(value)));
|
|
41603
|
+
}
|
|
41604
|
+
function describeRelaxations(looser, stricter) {
|
|
41605
|
+
const differences = [];
|
|
41606
|
+
for (const name of Object.keys(looser).sort()) {
|
|
41607
|
+
const looseArg = looser[name];
|
|
41608
|
+
const strictArg = stricter[name];
|
|
41609
|
+
if (!strictArg) {
|
|
41610
|
+
differences.push(`${name} adds an optional argument`);
|
|
41611
|
+
continue;
|
|
41612
|
+
}
|
|
41613
|
+
describeArgumentRelaxations(looseArg, strictArg, name, differences);
|
|
41614
|
+
}
|
|
41615
|
+
return differences;
|
|
41616
|
+
}
|
|
41617
|
+
function describeArgumentRelaxations(looser, stricter, path9, differences) {
|
|
41618
|
+
if (looser.required !== true && stricter.required === true) differences.push(`${path9} becomes optional`);
|
|
41619
|
+
if (looser.type === "object_array" && stricter.type === "object_array") {
|
|
41620
|
+
if (looser.max_items > stricter.max_items) differences.push(`${path9}.max_items increases from ${stricter.max_items} to ${looser.max_items}`);
|
|
41621
|
+
for (const name of Object.keys(looser.fields).sort()) {
|
|
41622
|
+
const looseField = looser.fields[name];
|
|
41623
|
+
const strictField = stricter.fields[name];
|
|
41624
|
+
if (!strictField) differences.push(`${path9}.${name} adds an optional field`);
|
|
41625
|
+
else describeArgumentRelaxations(looseField, strictField, `${path9}.${name}`, differences);
|
|
41626
|
+
}
|
|
41627
|
+
return;
|
|
41628
|
+
}
|
|
41629
|
+
if (looser.type === "object_array" || stricter.type === "object_array") return;
|
|
41630
|
+
if ((looser.max_length ?? Number.POSITIVE_INFINITY) > (stricter.max_length ?? Number.POSITIVE_INFINITY)) differences.push(`${path9}.max_length widens from ${displayBound(stricter.max_length)} to ${displayBound(looser.max_length)}`);
|
|
41631
|
+
if ((looser.minimum ?? Number.NEGATIVE_INFINITY) < (stricter.minimum ?? Number.NEGATIVE_INFINITY)) differences.push(`${path9}.minimum widens from ${displayBound(stricter.minimum)} to ${displayBound(looser.minimum)}`);
|
|
41632
|
+
if ((looser.maximum ?? Number.POSITIVE_INFINITY) > (stricter.maximum ?? Number.POSITIVE_INFINITY)) differences.push(`${path9}.maximum widens from ${displayBound(stricter.maximum)} to ${displayBound(looser.maximum)}`);
|
|
41633
|
+
if (stableString(looser.enum ?? []) !== stableString(stricter.enum ?? [])) differences.push(`${path9}.enum widens from ${displayEnum(stricter.enum)} to ${displayEnum(looser.enum)}`);
|
|
41634
|
+
}
|
|
41635
|
+
function stripArgumentDescriptions(args) {
|
|
41636
|
+
return Object.fromEntries(Object.entries(args).map(([name, arg]) => [name, stripArgumentDescription(arg)]));
|
|
41637
|
+
}
|
|
41638
|
+
function stripArgumentDescription(arg) {
|
|
41639
|
+
const { description: _description, ...rest } = arg;
|
|
41640
|
+
if (arg.type !== "object_array") return rest;
|
|
41641
|
+
return { ...rest, fields: Object.fromEntries(Object.entries(arg.fields).map(([name, field]) => [name, stripArgumentDescription(field)])) };
|
|
41642
|
+
}
|
|
41643
|
+
function sameExtensions(left, right) {
|
|
41644
|
+
const extensionEntries = (value) => Object.fromEntries(Object.entries(value).filter(([key]) => key.startsWith("x-")));
|
|
41645
|
+
return stableString(extensionEntries(left)) === stableString(extensionEntries(right));
|
|
41646
|
+
}
|
|
41647
|
+
function normalizeIdentifier(value) {
|
|
41648
|
+
return tokenizeIdentifier(value).join("_");
|
|
41649
|
+
}
|
|
41650
|
+
function capabilityPath(name) {
|
|
41651
|
+
return `$.capabilities[name=${JSON.stringify(name)}]`;
|
|
41652
|
+
}
|
|
41653
|
+
function tokenizeIdentifier(value) {
|
|
41654
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
41655
|
+
}
|
|
41656
|
+
function stableString(value) {
|
|
41657
|
+
return JSON.stringify(stableValue(value));
|
|
41658
|
+
}
|
|
41659
|
+
function stableValue(value) {
|
|
41660
|
+
if (Array.isArray(value)) return value.map(stableValue).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
41661
|
+
if (!value || typeof value !== "object") return value;
|
|
41662
|
+
return Object.fromEntries(Object.entries(value).filter(([, nested]) => nested !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, nested]) => [key, stableValue(nested)]));
|
|
41663
|
+
}
|
|
41664
|
+
function displayBound(value) {
|
|
41665
|
+
return value === void 0 ? "unbounded" : String(value);
|
|
41666
|
+
}
|
|
41667
|
+
function displayEnum(value) {
|
|
41668
|
+
return value?.length ? JSON.stringify([...value].map(stableValue).sort()) : "any value";
|
|
41669
|
+
}
|
|
41670
|
+
|
|
41671
|
+
// apps/runner/src/contract-tools.ts
|
|
41379
41672
|
async function loadReviewedContract(sourcePath) {
|
|
41380
41673
|
const absolute = path5.resolve(sourcePath);
|
|
41381
41674
|
const sourceText = await fs5.readFile(absolute, "utf8");
|
|
@@ -41478,13 +41771,15 @@ function lintContract(contract, options = {}) {
|
|
|
41478
41771
|
add({ code: "IRREVERSIBLE_DELETE_REVIEW", severity: "warning", path: `${base}.proposal.reversibility`, message: `${capability.name} performs a hard delete without reviewed inverse compensation.` });
|
|
41479
41772
|
}
|
|
41480
41773
|
}
|
|
41774
|
+
const surface = analyzeCapabilitySurface(contract);
|
|
41775
|
+
for (const finding of surface.findings) add(finding);
|
|
41481
41776
|
issues.sort((left, right) => severityRank(left.severity) - severityRank(right.severity) || left.code.localeCompare(right.code) || left.path.localeCompare(right.path));
|
|
41482
41777
|
const summary = {
|
|
41483
41778
|
errors: issues.filter((issue) => issue.severity === "error").length,
|
|
41484
41779
|
warnings: issues.filter((issue) => issue.severity === "warning").length,
|
|
41485
41780
|
info: issues.filter((issue) => issue.severity === "info").length
|
|
41486
41781
|
};
|
|
41487
|
-
return { ok: summary.errors === 0, issues, summary };
|
|
41782
|
+
return { ok: summary.errors === 0, issues, summary, surface: surface.summary };
|
|
41488
41783
|
}
|
|
41489
41784
|
function formatContractLint(result, format) {
|
|
41490
41785
|
if (format === "json") return `${JSON.stringify(result, null, 2)}
|
|
@@ -41499,13 +41794,16 @@ function formatContractLint(result, format) {
|
|
|
41499
41794
|
ruleId: issue.code,
|
|
41500
41795
|
level: issue.severity === "error" ? "error" : issue.severity === "warning" ? "warning" : "note",
|
|
41501
41796
|
message: { text: issue.message },
|
|
41502
|
-
locations: [{ physicalLocation: { artifactLocation: { uri: issue.path } } }]
|
|
41797
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: issue.path } } }],
|
|
41798
|
+
...issue.details ? { properties: issue.details } : {}
|
|
41503
41799
|
}))
|
|
41504
41800
|
}]
|
|
41505
41801
|
}, null, 2)}
|
|
41506
41802
|
`;
|
|
41507
41803
|
}
|
|
41508
41804
|
const lines = result.issues.map((issue) => `${issue.severity.toUpperCase()} ${issue.code} ${issue.path}: ${issue.message}`);
|
|
41805
|
+
const denseTargets = result.surface.targets.filter((target2) => target2.density_warning).length;
|
|
41806
|
+
lines.push(`Surface: ${result.surface.total_capabilities} model-facing capabilities across ${result.surface.target_count} targets; ${denseTargets} target(s) above the advisory density threshold of ${result.surface.density_review_threshold}`);
|
|
41509
41807
|
lines.push(`Summary: ${result.summary.errors} error / ${result.summary.warnings} warning / ${result.summary.info} info`);
|
|
41510
41808
|
return `${lines.join("\n")}
|
|
41511
41809
|
`;
|
|
@@ -42753,7 +43051,7 @@ function rate(value) {
|
|
|
42753
43051
|
// apps/runner/package.json
|
|
42754
43052
|
var package_default = {
|
|
42755
43053
|
name: "@synapsor/runner",
|
|
42756
|
-
version: "1.4.
|
|
43054
|
+
version: "1.4.123",
|
|
42757
43055
|
description: "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
|
|
42758
43056
|
license: "Apache-2.0",
|
|
42759
43057
|
type: "module",
|
|
@@ -121,6 +121,22 @@ synapsor-runner mcp serve --config ./synapsor.runner.json --store ./.synapsor/lo
|
|
|
121
121
|
capabilities cannot accidentally lose descriptions, returns hints, or numeric
|
|
122
122
|
patch bounds during review.
|
|
123
123
|
|
|
124
|
+
After compilation, run the canonical contract through the surface review:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
synapsor-runner contract lint ./synapsor.contract.json
|
|
128
|
+
synapsor-runner contract lint ./synapsor.contract.json --strict
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The default command keeps capability-surface findings advisory; `--strict` opts
|
|
132
|
+
into warning-as-failure CI policy. Review capabilities using a simple fitness
|
|
133
|
+
test: if the name is not a business operation an audit log would recognize, ask
|
|
134
|
+
whether the model should receive it. Lint also surfaces generic query-like
|
|
135
|
+
string arguments, more than eight capabilities on one target, and structurally
|
|
136
|
+
near-duplicate tools. These checks make breadth drift visible; they do not
|
|
137
|
+
change canonical validity or replace human review. See [Contract
|
|
138
|
+
Review](contract-review.md) for exact codes and behavior.
|
|
139
|
+
|
|
124
140
|
## DSL / JSON Capability Parity
|
|
125
141
|
|
|
126
142
|
The DSL compiles to canonical `@synapsor/spec` JSON. It must not silently weaken
|
package/docs/contract-review.md
CHANGED
|
@@ -33,9 +33,42 @@ cover objective review gaps such as missing descriptions/evidence, unbounded
|
|
|
33
33
|
strings, unresolved wiring, irreversible operations, and policy/writeback
|
|
34
34
|
contradictions. Lint does not claim to discover every sensitive column.
|
|
35
35
|
|
|
36
|
+
### Review Capability-Surface Fitness
|
|
37
|
+
|
|
38
|
+
Runner structurally limits capability **depth**: contracts cannot turn a model
|
|
39
|
+
argument into raw SQL or a free-form predicate, and trusted tenant/principal
|
|
40
|
+
scope is never model input. A separate review concern is capability **breadth**:
|
|
41
|
+
many individually narrow tools can accumulate until the total model-facing
|
|
42
|
+
surface is difficult to understand.
|
|
43
|
+
|
|
44
|
+
Use this fitness test during review:
|
|
45
|
+
|
|
46
|
+
> If a capability cannot be described as a named business operation an audit
|
|
47
|
+
> log would recognize, it probably should not be exposed yet.
|
|
48
|
+
|
|
49
|
+
`contract lint` reports these deterministic advisory warnings:
|
|
50
|
+
|
|
51
|
+
| Code | Review signal |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `SURFACE_GENERIC_ARGUMENT` | An un-enumerated string argument is literally named `filter`, `query`, `where`, `predicate`, or `sql`. Runner still does not interpolate it into SQL. |
|
|
54
|
+
| `SURFACE_TARGET_DENSITY` | More than eight capabilities target the same normalized source and object. Eight is a review threshold, not a runtime limit. |
|
|
55
|
+
| `SURFACE_OPERATION_NAMING` | A capability name does not match the conservative named-business-operation heuristic. |
|
|
56
|
+
| `SURFACE_NEAR_DUPLICATE` | Two capabilities have the same target, kind, reviewed fields, targeting, write/approval shape, and identical or directionally loosened arguments. |
|
|
57
|
+
|
|
58
|
+
The default command exits successfully when these advisories are the only
|
|
59
|
+
findings. `--strict` (or `--fail-on warning`) deliberately lets a team turn all
|
|
60
|
+
warnings into a CI policy gate without changing canonical contract validity.
|
|
61
|
+
Text, JSON, and SARIF use the same stable finding set and deterministic order.
|
|
62
|
+
|
|
63
|
+
Passing these checks does not prove that a surface is safe or well designed.
|
|
64
|
+
Review which operations each agent actually needs; use narrower contracts or
|
|
65
|
+
Runner/client deployments instead of exposing every organization capability to
|
|
66
|
+
every model.
|
|
67
|
+
|
|
36
68
|
Expected successful output ends with:
|
|
37
69
|
|
|
38
70
|
```text
|
|
71
|
+
Surface: N model-facing capabilities across M targets; 0 target(s) above the advisory density threshold of 8
|
|
39
72
|
Summary: 0 error / 0 warning / N info
|
|
40
73
|
```
|
|
41
74
|
|
package/docs/release-notes.md
CHANGED
|
@@ -10,7 +10,25 @@ npx -y -p @synapsor/runner synapsor-runner demo --quick
|
|
|
10
10
|
The OSS runner command is `synapsor-runner`. The `synapsor` command is reserved
|
|
11
11
|
for the Synapsor Cloud CLI.
|
|
12
12
|
|
|
13
|
-
## 1.4.
|
|
13
|
+
## 1.4.123 (prepared, not published)
|
|
14
|
+
|
|
15
|
+
### Advisory capability-surface fitness lint
|
|
16
|
+
|
|
17
|
+
- `contract lint` now reports high-signal breadth-drift advisories for generic
|
|
18
|
+
query/predicate-style string arguments, capability density above eight on one
|
|
19
|
+
target, operation names that do not read as business actions, and structural
|
|
20
|
+
near-duplicates with identical or loosened arguments.
|
|
21
|
+
- Findings are deterministic across declaration order and share stable codes in
|
|
22
|
+
text, JSON, and SARIF. JSON/SARIF include reviewer-safe metrics and structural
|
|
23
|
+
differences without reading a database or environment values.
|
|
24
|
+
- Advisory-only lint still exits successfully by default. Teams may opt into a
|
|
25
|
+
CI policy gate with `--strict` or `--fail-on warning`; canonical validity and
|
|
26
|
+
runtime enforcement are unchanged.
|
|
27
|
+
|
|
28
|
+
Prepared package version: `@synapsor/runner@1.4.123`. Spec, DSL, and Cloud CLI
|
|
29
|
+
packages are unchanged.
|
|
30
|
+
|
|
31
|
+
## 1.4.122 (2026-07-16)
|
|
14
32
|
|
|
15
33
|
### Trusted principal scope and Cloud-linked authority
|
|
16
34
|
|
|
@@ -142,7 +142,10 @@ credentials, approval tools, commit tools, or controls that widen reviewed
|
|
|
142
142
|
tables/columns.
|
|
143
143
|
|
|
144
144
|
Contract lint and tests are review aids rather than a proof of complete
|
|
145
|
-
security.
|
|
145
|
+
security. Capability breadth can still drift as narrow tools accumulate;
|
|
146
|
+
surface-fitness lint makes high-signal generic, dense, poorly named, and
|
|
147
|
+
near-duplicate surfaces visible for review but does not enforce good design.
|
|
148
|
+
Scoped report exports omit evidence rows and kept-out values and are
|
|
146
149
|
tamper-evident when their digest/signature verifies; local SQLite is not an
|
|
147
150
|
immutable compliance service. Graduated-trust evaluation is operator-only,
|
|
148
151
|
disabled by default, and can recommend/export a separate artifact but cannot
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
{
|
|
2
|
+
"spec_version": "0.1",
|
|
3
|
+
"kind": "SynapsorContract",
|
|
4
|
+
"metadata": {
|
|
5
|
+
"name": "capability surface fitness fixture",
|
|
6
|
+
"description": "Exercises advisory capability-breadth review without changing contract validity.",
|
|
7
|
+
"version": "1.0.0"
|
|
8
|
+
},
|
|
9
|
+
"resources": [
|
|
10
|
+
{
|
|
11
|
+
"name": "billing_invoices",
|
|
12
|
+
"engine": "postgres",
|
|
13
|
+
"schema": "public",
|
|
14
|
+
"table": "invoices",
|
|
15
|
+
"type": "table",
|
|
16
|
+
"primary_key": "id",
|
|
17
|
+
"tenant_key": "tenant_id",
|
|
18
|
+
"conflict_key": "updated_at"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"contexts": [
|
|
22
|
+
{
|
|
23
|
+
"name": "billing_operator",
|
|
24
|
+
"bindings": [
|
|
25
|
+
{
|
|
26
|
+
"name": "tenant_id",
|
|
27
|
+
"source": "environment",
|
|
28
|
+
"key": "SYNAPSOR_TENANT_ID",
|
|
29
|
+
"required": true
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"tenant_binding": "tenant_id"
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"capabilities": [
|
|
36
|
+
{
|
|
37
|
+
"name": "billing.execute",
|
|
38
|
+
"description": "Look up one reviewed invoice reference.",
|
|
39
|
+
"returns_hint": "One allowlisted invoice row.",
|
|
40
|
+
"kind": "read",
|
|
41
|
+
"context": "billing_operator",
|
|
42
|
+
"source": "local_postgres",
|
|
43
|
+
"subject": { "resource": "billing_invoices" },
|
|
44
|
+
"args": {
|
|
45
|
+
"filter": { "type": "string", "required": true, "max_length": 128 }
|
|
46
|
+
},
|
|
47
|
+
"lookup": { "id_from_arg": "filter" },
|
|
48
|
+
"visible_fields": ["id", "tenant_id", "status"],
|
|
49
|
+
"kept_out_fields": ["card_token", "internal_note"],
|
|
50
|
+
"evidence": { "required": true, "query_audit": true },
|
|
51
|
+
"max_rows": 1
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"name": "billing.inspect_invoice_primary",
|
|
55
|
+
"description": "Inspect one primary invoice.",
|
|
56
|
+
"returns_hint": "One allowlisted invoice row.",
|
|
57
|
+
"kind": "read",
|
|
58
|
+
"context": "billing_operator",
|
|
59
|
+
"source": "local_postgres",
|
|
60
|
+
"subject": { "resource": "billing_invoices" },
|
|
61
|
+
"args": {
|
|
62
|
+
"invoice_id": { "type": "string", "required": true, "max_length": 128 }
|
|
63
|
+
},
|
|
64
|
+
"lookup": { "id_from_arg": "invoice_id" },
|
|
65
|
+
"visible_fields": ["id", "tenant_id", "status"],
|
|
66
|
+
"kept_out_fields": ["card_token", "internal_note"],
|
|
67
|
+
"evidence": { "required": true, "query_audit": true },
|
|
68
|
+
"max_rows": 1
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "billing.inspect_invoice_copy",
|
|
72
|
+
"description": "Inspect the same reviewed invoice shape for duplicate detection.",
|
|
73
|
+
"returns_hint": "One allowlisted invoice row.",
|
|
74
|
+
"kind": "read",
|
|
75
|
+
"context": "billing_operator",
|
|
76
|
+
"source": "local_postgres",
|
|
77
|
+
"subject": { "resource": "billing_invoices" },
|
|
78
|
+
"args": {
|
|
79
|
+
"invoice_id": { "type": "string", "required": true, "max_length": 128 }
|
|
80
|
+
},
|
|
81
|
+
"lookup": { "id_from_arg": "invoice_id" },
|
|
82
|
+
"visible_fields": ["id", "tenant_id", "status"],
|
|
83
|
+
"kept_out_fields": ["card_token", "internal_note"],
|
|
84
|
+
"evidence": { "required": true, "query_audit": true },
|
|
85
|
+
"max_rows": 1
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "billing.inspect_invoice_secondary",
|
|
89
|
+
"description": "Inspect one secondary invoice.",
|
|
90
|
+
"returns_hint": "One allowlisted invoice row.",
|
|
91
|
+
"kind": "read",
|
|
92
|
+
"context": "billing_operator",
|
|
93
|
+
"source": "local_postgres",
|
|
94
|
+
"subject": { "resource": "billing_invoices" },
|
|
95
|
+
"args": {
|
|
96
|
+
"secondary_invoice_id": { "type": "string", "required": true, "max_length": 128 }
|
|
97
|
+
},
|
|
98
|
+
"lookup": { "id_from_arg": "secondary_invoice_id" },
|
|
99
|
+
"visible_fields": ["id", "tenant_id", "status"],
|
|
100
|
+
"kept_out_fields": ["card_token", "internal_note"],
|
|
101
|
+
"evidence": { "required": true, "query_audit": true },
|
|
102
|
+
"max_rows": 1
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"name": "billing.inspect_invoice_archived",
|
|
106
|
+
"description": "Inspect one archived invoice.",
|
|
107
|
+
"returns_hint": "One allowlisted invoice row.",
|
|
108
|
+
"kind": "read",
|
|
109
|
+
"context": "billing_operator",
|
|
110
|
+
"source": "local_postgres",
|
|
111
|
+
"subject": { "resource": "billing_invoices" },
|
|
112
|
+
"args": {
|
|
113
|
+
"archived_invoice_id": { "type": "string", "required": true, "max_length": 128 }
|
|
114
|
+
},
|
|
115
|
+
"lookup": { "id_from_arg": "archived_invoice_id" },
|
|
116
|
+
"visible_fields": ["id", "tenant_id", "status"],
|
|
117
|
+
"kept_out_fields": ["card_token", "internal_note"],
|
|
118
|
+
"evidence": { "required": true, "query_audit": true },
|
|
119
|
+
"max_rows": 1
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"name": "billing.inspect_invoice_disputed",
|
|
123
|
+
"description": "Inspect one disputed invoice.",
|
|
124
|
+
"returns_hint": "One allowlisted invoice row.",
|
|
125
|
+
"kind": "read",
|
|
126
|
+
"context": "billing_operator",
|
|
127
|
+
"source": "local_postgres",
|
|
128
|
+
"subject": { "resource": "billing_invoices" },
|
|
129
|
+
"args": {
|
|
130
|
+
"disputed_invoice_id": { "type": "string", "required": true, "max_length": 128 }
|
|
131
|
+
},
|
|
132
|
+
"lookup": { "id_from_arg": "disputed_invoice_id" },
|
|
133
|
+
"visible_fields": ["id", "tenant_id", "status"],
|
|
134
|
+
"kept_out_fields": ["card_token", "internal_note"],
|
|
135
|
+
"evidence": { "required": true, "query_audit": true },
|
|
136
|
+
"max_rows": 1
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"name": "billing.inspect_invoice_overdue",
|
|
140
|
+
"description": "Inspect one overdue invoice.",
|
|
141
|
+
"returns_hint": "One allowlisted invoice row.",
|
|
142
|
+
"kind": "read",
|
|
143
|
+
"context": "billing_operator",
|
|
144
|
+
"source": "local_postgres",
|
|
145
|
+
"subject": { "resource": "billing_invoices" },
|
|
146
|
+
"args": {
|
|
147
|
+
"overdue_invoice_id": { "type": "string", "required": true, "max_length": 128 }
|
|
148
|
+
},
|
|
149
|
+
"lookup": { "id_from_arg": "overdue_invoice_id" },
|
|
150
|
+
"visible_fields": ["id", "tenant_id", "status"],
|
|
151
|
+
"kept_out_fields": ["card_token", "internal_note"],
|
|
152
|
+
"evidence": { "required": true, "query_audit": true },
|
|
153
|
+
"max_rows": 1
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"name": "billing.inspect_invoice_paid",
|
|
157
|
+
"description": "Inspect one paid invoice.",
|
|
158
|
+
"returns_hint": "One allowlisted invoice row.",
|
|
159
|
+
"kind": "read",
|
|
160
|
+
"context": "billing_operator",
|
|
161
|
+
"source": "local_postgres",
|
|
162
|
+
"subject": { "resource": "billing_invoices" },
|
|
163
|
+
"args": {
|
|
164
|
+
"paid_invoice_id": { "type": "string", "required": true, "max_length": 128 }
|
|
165
|
+
},
|
|
166
|
+
"lookup": { "id_from_arg": "paid_invoice_id" },
|
|
167
|
+
"visible_fields": ["id", "tenant_id", "status"],
|
|
168
|
+
"kept_out_fields": ["card_token", "internal_note"],
|
|
169
|
+
"evidence": { "required": true, "query_audit": true },
|
|
170
|
+
"max_rows": 1
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"name": "billing.inspect_invoice_voided",
|
|
174
|
+
"description": "Inspect one voided invoice.",
|
|
175
|
+
"returns_hint": "One allowlisted invoice row.",
|
|
176
|
+
"kind": "read",
|
|
177
|
+
"context": "billing_operator",
|
|
178
|
+
"source": "local_postgres",
|
|
179
|
+
"subject": { "resource": "billing_invoices" },
|
|
180
|
+
"args": {
|
|
181
|
+
"voided_invoice_id": { "type": "string", "required": true, "max_length": 128 }
|
|
182
|
+
},
|
|
183
|
+
"lookup": { "id_from_arg": "voided_invoice_id" },
|
|
184
|
+
"visible_fields": ["id", "tenant_id", "status"],
|
|
185
|
+
"kept_out_fields": ["card_token", "internal_note"],
|
|
186
|
+
"evidence": { "required": true, "query_audit": true },
|
|
187
|
+
"max_rows": 1
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synapsor/runner",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.123",
|
|
4
4
|
"description": "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|