skillscript-runtime 0.19.8 → 0.19.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/README.md +31 -27
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/connectors/config.d.ts.map +1 -1
- package/dist/connectors/config.js +17 -0
- package/dist/connectors/config.js.map +1 -1
- package/dist/connectors/mcp-remote.d.ts.map +1 -1
- package/dist/connectors/mcp-remote.js +10 -1
- package/dist/connectors/mcp-remote.js.map +1 -1
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +151 -0
- package/dist/lint.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +9 -0
- package/dist/runtime.js.map +1 -1
- package/docs/adopter-playbook.md +17 -5
- package/docs/configuration.md +13 -2
- package/docs/language-reference.md +181 -159
- package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
- package/package.json +1 -1
- package/scaffold/connectors.json +5 -1
package/dist/lint.js
CHANGED
|
@@ -535,6 +535,155 @@ const UNKNOWN_CONNECTOR = {
|
|
|
535
535
|
return findings;
|
|
536
536
|
},
|
|
537
537
|
};
|
|
538
|
+
// v0.19.10 — catches the `$ <connector> <tool>` foot-gun: author writes the
|
|
539
|
+
// connector name + tool name space-separated (CLI muscle memory: `git status`),
|
|
540
|
+
// but skillscript's bare form treats the FIRST token as the tool name → the
|
|
541
|
+
// dispatch sends `name: "<connector>"` to the resolved server, which replies
|
|
542
|
+
// "Tool with name 'X' not found" — pointing the author at the wrong layer
|
|
543
|
+
// (looks like the connector is broken). Closes Perry's `650c5a9c` Finding 1.
|
|
544
|
+
const CONNECTOR_AS_TOOL = {
|
|
545
|
+
id: "connector-as-tool",
|
|
546
|
+
severity: "error",
|
|
547
|
+
description: "A bare `$ <connector> <tool>` op was written space-separated, but skillscript's bare-form dispatch uses the first token as the TOOL name. The connector name is being interpreted as a tool, which fails at the MCP server with a misdirecting error.",
|
|
548
|
+
remediation: "Two correct forms: `$ <connector>.<tool> args ...` (dotted; explicit connector.tool routing) OR `$ <tool> args ...` (bare tool; runtime resolves to the owning connector). Don't write `$ <connector> <tool>` space-separated.",
|
|
549
|
+
check: (ctx) => {
|
|
550
|
+
if (ctx.mcpConnectorNames === undefined)
|
|
551
|
+
return [];
|
|
552
|
+
const known = new Set(ctx.mcpConnectorNames);
|
|
553
|
+
if (known.size === 0)
|
|
554
|
+
return [];
|
|
555
|
+
const findings = [];
|
|
556
|
+
const reported = new Set();
|
|
557
|
+
for (const [targetName, target] of ctx.parsed.targets) {
|
|
558
|
+
walkOps(target.ops, (op) => {
|
|
559
|
+
// Only bare-form `$` ops (no explicit `connector.tool` dot).
|
|
560
|
+
if (op.kind !== "$" || op.mcpConnector !== undefined)
|
|
561
|
+
return;
|
|
562
|
+
const body = op.body.trimStart();
|
|
563
|
+
// First token = the would-be tool name (or kwarg target). Match
|
|
564
|
+
// `<token1> <token2>` where token1 is a connector name AND token2
|
|
565
|
+
// is a BARE identifier NOT followed by `=` (which would make it a
|
|
566
|
+
// kwarg key — the legit same-name shape `$ data_write content="..."`
|
|
567
|
+
// where `data_write` is both the connector and tool name).
|
|
568
|
+
// `\b` after the second identifier prevents the greedy quantifier
|
|
569
|
+
// from backtracking into a partial match: without `\b`, `content`
|
|
570
|
+
// followed by `=` makes the lookahead fail, and the engine backs off
|
|
571
|
+
// to `conten`/`conte`/... until the lookahead is satisfied (false
|
|
572
|
+
// positive on the legitimate same-name kwarg shape).
|
|
573
|
+
const m = /^([a-z_][a-z0-9_-]*)\s+([A-Za-z_][\w-]*)\b(?!\s*=)/.exec(body);
|
|
574
|
+
if (m === null)
|
|
575
|
+
return;
|
|
576
|
+
const firstToken = m[1];
|
|
577
|
+
if (!known.has(firstToken))
|
|
578
|
+
return;
|
|
579
|
+
const secondToken = m[2];
|
|
580
|
+
const key = `${targetName}:${firstToken}`;
|
|
581
|
+
if (reported.has(key))
|
|
582
|
+
return;
|
|
583
|
+
reported.add(key);
|
|
584
|
+
findings.push({
|
|
585
|
+
rule: "connector-as-tool",
|
|
586
|
+
severity: "error",
|
|
587
|
+
message: `\`$ ${firstToken} ${secondToken} ...\` in target '${targetName}' — \`${firstToken}\` is a connector name, not a tool. Bare-form dispatch treats the first token as the tool name, so this sends \`name: "${firstToken}"\` to the server (which replies \"Tool '${firstToken}' not found\" — a misdirecting error). Two correct forms: \`$ ${firstToken}.${secondToken} ...\` (dotted; explicit connector.tool) OR \`$ ${secondToken} ...\` (bare tool; runtime resolves to owning connector).`,
|
|
588
|
+
block: targetName,
|
|
589
|
+
extras: { connector: firstToken, would_be_tool: secondToken },
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
return findings;
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
// v0.19.10 — tier-3 advisory closing Perry's `650c5a9c` Finding 2 partially.
|
|
597
|
+
// Per-MCP-server result shape varies: some servers return JSON-as-text in
|
|
598
|
+
// `content[0].text`, which the runtime's `unwrapToolResult` JSON.parses
|
|
599
|
+
// cleanly. Others return prose-wrapped or multi-content responses that
|
|
600
|
+
// fail to parse → the bound var is a string, not a structured object.
|
|
601
|
+
// `${R|length}` then silently returns char-count of the string instead of
|
|
602
|
+
// element-count of the (intended) array — silent-wrong, the worst class.
|
|
603
|
+
//
|
|
604
|
+
// Narrow to `${R|length}` specifically: it's the silent-wrong path.
|
|
605
|
+
// `${R.field}` on a string raises UnresolvedVariableError (loud), so the
|
|
606
|
+
// runtime tells the author already. `|length` is the trap.
|
|
607
|
+
//
|
|
608
|
+
// Skip the advisory when the skill already does `$ json_parse ${R}`
|
|
609
|
+
// somewhere — the author already defended.
|
|
610
|
+
const REMOTE_RESULT_NEEDS_PARSE = {
|
|
611
|
+
id: "remote-result-needs-parse",
|
|
612
|
+
severity: "info",
|
|
613
|
+
description: "A `${R|length}` access on a value bound by a `$` dispatch. Per-MCP-server result shape varies: if the connector's server returns prose-wrapped or non-JSON text in `content[0].text`, the runtime's auto-parse falls back to the raw string and `|length` returns the string's char-count (NOT the intended array element-count). Silent-wrong is the trap.",
|
|
614
|
+
remediation: "If `${R|length}` returns suspicious counts (e.g., 1394 when you expected 5), add `$ json_parse ${R} -> P` after the dispatch and access via `${P|length}`. This advisory suppresses itself when the skill already does `$ json_parse ${R}` somewhere — your defensive parse is taken as intent.",
|
|
615
|
+
check: (ctx) => {
|
|
616
|
+
// Bindings from $ ops that aren't json_parse itself.
|
|
617
|
+
const dollarBindings = new Map(); // bindVar → targetName
|
|
618
|
+
for (const [targetName, target] of ctx.parsed.targets) {
|
|
619
|
+
walkOps(target.ops, (op) => {
|
|
620
|
+
if (op.kind !== "$" || op.outputVar === undefined)
|
|
621
|
+
return;
|
|
622
|
+
// Skip json_parse bindings — those produce parsed structure by design.
|
|
623
|
+
if (/^json_parse\b|\.json_parse\b/.test(op.body))
|
|
624
|
+
return;
|
|
625
|
+
dollarBindings.set(op.outputVar, targetName);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (dollarBindings.size === 0)
|
|
629
|
+
return [];
|
|
630
|
+
// Collect var names that the skill already passes through `json_parse`
|
|
631
|
+
// anywhere — author has defended; suppress the advisory for those.
|
|
632
|
+
const alreadyDefended = new Set();
|
|
633
|
+
for (const target of ctx.parsed.targets.values()) {
|
|
634
|
+
walkOps(target.ops, (op) => {
|
|
635
|
+
if (op.kind !== "$")
|
|
636
|
+
return;
|
|
637
|
+
const m = /^json_parse\s+\$[({]([A-Za-z_]\w*)/.exec(op.body);
|
|
638
|
+
if (m !== null)
|
|
639
|
+
alreadyDefended.add(m[1]);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
const findings = [];
|
|
643
|
+
const reported = new Set();
|
|
644
|
+
const lengthRe = /\$[({]([A-Za-z_]\w*)\|length\b/g;
|
|
645
|
+
for (const [targetName, target] of ctx.parsed.targets) {
|
|
646
|
+
const scan = (s) => {
|
|
647
|
+
let m;
|
|
648
|
+
lengthRe.lastIndex = 0;
|
|
649
|
+
while ((m = lengthRe.exec(s)) !== null) {
|
|
650
|
+
const refBase = m[1];
|
|
651
|
+
if (!dollarBindings.has(refBase))
|
|
652
|
+
continue;
|
|
653
|
+
if (alreadyDefended.has(refBase))
|
|
654
|
+
continue;
|
|
655
|
+
const key = `${targetName}:${refBase}`;
|
|
656
|
+
if (reported.has(key))
|
|
657
|
+
continue;
|
|
658
|
+
reported.add(key);
|
|
659
|
+
findings.push({
|
|
660
|
+
rule: "remote-result-needs-parse",
|
|
661
|
+
severity: "info",
|
|
662
|
+
message: `\`\${${refBase}|length}\` in target '${targetName}' takes the length of a value bound by a \`$\` dispatch. If the connector's MCP server returns prose-wrapped or non-JSON text, the runtime's auto-parse falls back to the raw string and \`|length\` returns the string's char-count instead of element-count — silent-wrong. If results look suspiciously large, add \`$ json_parse \${${refBase}} -> P\` after the dispatch and use \`\${P|length}\`.`,
|
|
663
|
+
block: targetName,
|
|
664
|
+
extras: { bind_var: refBase },
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
const visit = (op) => {
|
|
669
|
+
if (op.body !== undefined)
|
|
670
|
+
scan(op.body);
|
|
671
|
+
if (op.setValue !== undefined)
|
|
672
|
+
scan(op.setValue);
|
|
673
|
+
if (op.foreachBody !== undefined)
|
|
674
|
+
op.foreachBody.forEach(visit);
|
|
675
|
+
if (op.ifBranches !== undefined)
|
|
676
|
+
op.ifBranches.forEach((b) => b.body.forEach(visit));
|
|
677
|
+
if (op.ifElseBody !== undefined)
|
|
678
|
+
op.ifElseBody.forEach(visit);
|
|
679
|
+
};
|
|
680
|
+
target.ops.forEach(visit);
|
|
681
|
+
if (target.elseBlock !== undefined)
|
|
682
|
+
target.elseBlock.forEach(visit);
|
|
683
|
+
}
|
|
684
|
+
return findings;
|
|
685
|
+
},
|
|
686
|
+
};
|
|
538
687
|
// v0.16.4 — `$ llm prompt="..." model="X"` where X matches neither any
|
|
539
688
|
// registered LocalModel alias name NOR any `models_available` entry from
|
|
540
689
|
// any registered LocalModel's `manifest()` payload. Tier-2 warning.
|
|
@@ -3054,6 +3203,7 @@ const RULES = [
|
|
|
3054
3203
|
UNKNOWN_SKILL_REFERENCE,
|
|
3055
3204
|
UNKNOWN_TEMPLATE_REFERENCE,
|
|
3056
3205
|
UNKNOWN_CONNECTOR,
|
|
3206
|
+
CONNECTOR_AS_TOOL,
|
|
3057
3207
|
UNKNOWN_CONNECTOR_CLASS,
|
|
3058
3208
|
UNWIRED_PRIMARY_CONNECTOR,
|
|
3059
3209
|
DISALLOWED_TOOL,
|
|
@@ -3106,6 +3256,7 @@ const RULES = [
|
|
|
3106
3256
|
ADDRESS_ROUTED_WAKE_INFO,
|
|
3107
3257
|
BODY_TEMPLATE_DETECTED,
|
|
3108
3258
|
EMIT_WITH_TEMPLATE,
|
|
3259
|
+
REMOTE_RESULT_NEEDS_PARSE,
|
|
3109
3260
|
];
|
|
3110
3261
|
/** Read-only view of the rule registry — for tooling that introspects v1 rules. */
|
|
3111
3262
|
export function listRules() {
|