skillscript-runtime 0.15.7 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -24
- package/dist/compile.js +25 -71
- package/dist/compile.js.map +1 -1
- package/dist/connectors/mcp-remote.d.ts.map +1 -1
- package/dist/connectors/mcp-remote.js +7 -1
- package/dist/connectors/mcp-remote.js.map +1 -1
- package/dist/errors.d.ts +13 -7
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +20 -13
- package/dist/errors.js.map +1 -1
- package/dist/help-content.d.ts.map +1 -1
- package/dist/help-content.js +78 -103
- package/dist/help-content.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +167 -228
- package/dist/lint.js.map +1 -1
- package/dist/mutation-gate.d.ts +7 -13
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +6 -11
- package/dist/mutation-gate.js.map +1 -1
- package/dist/parser.d.ts +25 -74
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +80 -272
- package/dist/parser.js.map +1 -1
- package/dist/runtime.d.ts +1 -10
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +76 -188
- package/dist/runtime.js.map +1 -1
- package/dist/safe-path.d.ts +20 -0
- package/dist/safe-path.d.ts.map +1 -0
- package/dist/safe-path.js +49 -0
- package/dist/safe-path.js.map +1 -0
- package/dist/skill-manager.js +1 -1
- package/dist/skill-manager.js.map +1 -1
- package/dist/trace.d.ts.map +1 -1
- package/dist/trace.js +26 -9
- package/dist/trace.js.map +1 -1
- package/docs/language-reference.md +3 -24
- package/examples/README.md +1 -2
- package/examples/connectors/McpConnectorTemplate/McpConnectorTemplate.ts +4 -5
- package/examples/connectors/SkillStoreTemplate/SkillStoreTemplate.ts +9 -9
- package/examples/custom-bootstrap.example.ts +4 -4
- package/examples/onboarding-scaffold/bootstrap.ts +7 -7
- package/examples/onboarding-scaffold/file-data-store.ts +3 -5
- package/examples/onboarding-scaffold/openai-local-model.ts +7 -8
- package/examples/onboarding-scaffold/tmux-shell-agent-connector.ts +4 -4
- package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
- package/package.json +1 -1
- package/examples/skillscripts/cut-release-tag.skill.md +0 -40
package/dist/lint.js
CHANGED
|
@@ -257,11 +257,11 @@ const UNDECLARED_VAR = {
|
|
|
257
257
|
for (const [targetName, target] of ctx.parsed.targets) {
|
|
258
258
|
const reported = new Set(); // dedupe per target
|
|
259
259
|
for (const op of target.ops) {
|
|
260
|
-
//
|
|
260
|
+
// Unsafe `shell` ops use bash `$(...)` syntax — handled by
|
|
261
261
|
// unsafe-shell-ambiguous-subst, which offers the dual rewrite
|
|
262
262
|
// (`$$(NAME)` for bash, `$(KNOWN_VAR)` for skillscript). Skip here
|
|
263
263
|
// to avoid double-reporting.
|
|
264
|
-
if (op.kind === "
|
|
264
|
+
if (op.kind === "shell" && op.policy === "unsafe")
|
|
265
265
|
continue;
|
|
266
266
|
for (const ref of extractVarRefs(op)) {
|
|
267
267
|
// Heuristic: dotted refs (targetname.output, MEMORY.field) pass
|
|
@@ -321,9 +321,9 @@ const MALFORMED_OP_GRAMMAR = {
|
|
|
321
321
|
id: "malformed-op-grammar",
|
|
322
322
|
severity: "error",
|
|
323
323
|
description: "An op line failed parser grammar validation. Surfaces parse errors that originate from op-specific shape.",
|
|
324
|
-
remediation: "Check the op's syntax against the language reference.
|
|
324
|
+
remediation: "Check the op's syntax against the language reference. `$ tool key=value ... -> VAR` for MCP dispatch; `verb(kwarg=value, ...) [-> VAR]` for runtime intrinsics.",
|
|
325
325
|
check: (ctx) => ctx.parsed.parseErrors
|
|
326
|
-
.filter((msg) => /Malformed `[
|
|
326
|
+
.filter((msg) => /Malformed `\$|Malformed function-call|Legacy `[~>&@!?]/.test(msg))
|
|
327
327
|
.map((msg) => ({
|
|
328
328
|
rule: "malformed-op-grammar",
|
|
329
329
|
severity: "error",
|
|
@@ -430,40 +430,6 @@ const UNKNOWN_SKILL_REFERENCE = {
|
|
|
430
430
|
return findings;
|
|
431
431
|
},
|
|
432
432
|
};
|
|
433
|
-
// v0.2.12 Bug 26. Tier-2 warning when a `>` retrieval op carries kwargs
|
|
434
|
-
// outside the documented set. Cold author wrote `since=1h` (hallucinated
|
|
435
|
-
// time-window predicate) and the kwarg passed silently. The documented
|
|
436
|
-
// kwarg set is mode/query/limit/connector/fallback plus filter shapes
|
|
437
|
-
// the connector advertises via staticCapabilities (out of band here).
|
|
438
|
-
const KNOWN_RETRIEVAL_KWARGS = new Set(["mode", "query", "limit", "connector", "fallback"]);
|
|
439
|
-
const UNKNOWN_RETRIEVAL_ARG = {
|
|
440
|
-
id: "unknown-retrieval-arg",
|
|
441
|
-
severity: "warning",
|
|
442
|
-
description: "A `>` retrieval op carries a kwarg outside the documented set (mode/query/limit/connector/fallback).",
|
|
443
|
-
remediation: "Remove the kwarg, or check it against your DataStore connector's documentation — extras pass through to the connector but unrecognized ones often indicate hallucinated syntax.",
|
|
444
|
-
check: (ctx) => {
|
|
445
|
-
const findings = [];
|
|
446
|
-
for (const [targetName, target] of ctx.parsed.targets) {
|
|
447
|
-
walkOps(target.ops, (op) => {
|
|
448
|
-
if (op.kind !== ">")
|
|
449
|
-
return;
|
|
450
|
-
const extras = op.retrievalParams?.extra ?? {};
|
|
451
|
-
for (const k of Object.keys(extras)) {
|
|
452
|
-
if (KNOWN_RETRIEVAL_KWARGS.has(k))
|
|
453
|
-
continue;
|
|
454
|
-
findings.push({
|
|
455
|
-
rule: "unknown-retrieval-arg",
|
|
456
|
-
severity: "warning",
|
|
457
|
-
message: `\`>\` op in target '${targetName}' carries unknown kwarg '${k}'. Documented kwargs: ${Array.from(KNOWN_RETRIEVAL_KWARGS).join(", ")}. If '${k}' is a connector-specific filter, confirm it against the connector's docs.`,
|
|
458
|
-
block: targetName,
|
|
459
|
-
extras: { unknown_kwarg: k },
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
return findings;
|
|
465
|
-
},
|
|
466
|
-
};
|
|
467
433
|
// v0.4.0 — `$ name.tool` references a connector name not registered.
|
|
468
434
|
// `connectorNames` is the authoritative list from the Registry (passed
|
|
469
435
|
// through LintOptions); when undefined (caller doesn't know what's
|
|
@@ -685,11 +651,19 @@ const UNVERIFIED_QUALIFIED_TOOL = {
|
|
|
685
651
|
// the lint message didn't explain the layering. Now: if all sites have
|
|
686
652
|
// fallback → info with layering explanation; if any site lacks it → error
|
|
687
653
|
// (unchanged).
|
|
654
|
+
// v0.16.0 — bare-form `$ <tool>` is reserved for runtime-intrinsic ops + tools
|
|
655
|
+
// whose name name-matches a registered MCP connector (the typed-contract pattern,
|
|
656
|
+
// e.g. `$ llm prompt=...` dispatches to the `llm` connector). Substrate-specific
|
|
657
|
+
// MCP tool dispatch (e.g. `amp_olsen_task`) MUST use named form `$ <connector>.<tool>`
|
|
658
|
+
// — no more "primary fallback" leniency. Closes the discipline-only-contract gap
|
|
659
|
+
// where adopters wrote bare-form expecting `primary` and silent-failed on cron-fired
|
|
660
|
+
// paths when primary wasn't registered.
|
|
661
|
+
const BARE_FORM_INTRINSICS = new Set(["execute_skill", "json_parse"]);
|
|
688
662
|
const UNWIRED_PRIMARY_CONNECTOR = {
|
|
689
663
|
id: "unwired-primary-connector",
|
|
690
664
|
severity: "error",
|
|
691
|
-
description: "A bare `$ TOOL` op (no connector prefix) routes to
|
|
692
|
-
remediation: "
|
|
665
|
+
description: "A bare `$ TOOL` op (no connector prefix) routes only to (a) a runtime-intrinsic op or (b) a wired connector whose name matches the op name. v0.16.0 removed the `primary` fallback — substrate-specific MCP tools require named form `$ <connector>.<tool>`.",
|
|
666
|
+
remediation: "Use named form `$ <connector>.<tool>` for substrate-specific MCP dispatch (e.g., `$ amp.amp_olsen_task` instead of `$ amp_olsen_task`). Bare form is reserved for runtime intrinsics (`execute_skill`, `json_parse`) and typed-contract ops where the tool name matches a wired connector (e.g., `$ llm prompt=...` if `llm` is wired).",
|
|
693
667
|
check: (ctx) => {
|
|
694
668
|
if (ctx.mcpConnectorNames === undefined)
|
|
695
669
|
return [];
|
|
@@ -700,22 +674,17 @@ const UNWIRED_PRIMARY_CONNECTOR = {
|
|
|
700
674
|
walkOps(target.ops, (op) => {
|
|
701
675
|
if (op.kind !== "$" || op.mcpConnector !== undefined)
|
|
702
676
|
return;
|
|
703
|
-
// Built-in intercepts that don't need a connector at all.
|
|
704
677
|
const m = /^([A-Za-z_][\w:-]*)/.exec(op.body);
|
|
705
678
|
if (m === null)
|
|
706
679
|
return;
|
|
707
680
|
const toolName = m[1];
|
|
708
|
-
if (toolName
|
|
681
|
+
if (BARE_FORM_INTRINSICS.has(toolName))
|
|
709
682
|
return;
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
// fix to prevent the v0.7.2-shape regression of lint-fails-then-
|
|
714
|
-
// user-never-reaches-runtime.)
|
|
683
|
+
// Name-match: bare op name matches a wired connector → typed-contract
|
|
684
|
+
// pattern (e.g., `$ llm` against wired `llm` connector). Runtime
|
|
685
|
+
// dispatch resolver routes directly.
|
|
715
686
|
if (ctx.mcpConnectorNames.includes(toolName))
|
|
716
687
|
return;
|
|
717
|
-
if (ctx.mcpConnectorNames.includes("primary"))
|
|
718
|
-
return;
|
|
719
688
|
const key = `${targetName}:${toolName}`;
|
|
720
689
|
const hasFallback = op.fallback !== undefined;
|
|
721
690
|
const existing = groups.get(key);
|
|
@@ -742,7 +711,7 @@ const UNWIRED_PRIMARY_CONNECTOR = {
|
|
|
742
711
|
findings.push({
|
|
743
712
|
rule: "unwired-primary-connector",
|
|
744
713
|
severity: "error",
|
|
745
|
-
message: `\`$ ${toolName}\` in target '${targetName}' is a
|
|
714
|
+
message: `\`$ ${toolName}\` in target '${targetName}' is bare-form but '${toolName}' is not a runtime intrinsic AND doesn't match any wired MCP connector. Bare form is reserved for typed-contract / intrinsic ops. Use named form \`$ <connector>.${toolName}\` for substrate-specific MCP dispatch. Wired connectors: ${wired}.`,
|
|
746
715
|
block: targetName,
|
|
747
716
|
extras: { tool: toolName },
|
|
748
717
|
});
|
|
@@ -809,14 +778,6 @@ const UNPARSED_JSON_FIELD_ACCESS = {
|
|
|
809
778
|
for (const b of op.ifBranches)
|
|
810
779
|
reportIfMatches(b.cond, targetName);
|
|
811
780
|
}
|
|
812
|
-
if (op.retrievalParams !== undefined) {
|
|
813
|
-
reportIfMatches(op.retrievalParams.query, targetName);
|
|
814
|
-
for (const v of Object.values(op.retrievalParams.extra))
|
|
815
|
-
reportIfMatches(String(v), targetName);
|
|
816
|
-
}
|
|
817
|
-
if (op.localModelParams !== undefined) {
|
|
818
|
-
reportIfMatches(op.localModelParams.prompt, targetName);
|
|
819
|
-
}
|
|
820
781
|
if (op.ampParams !== undefined) {
|
|
821
782
|
for (const v of Object.values(op.ampParams.args))
|
|
822
783
|
reportIfMatches(v, targetName);
|
|
@@ -892,26 +853,60 @@ const DISABLED_SKILL_REFERENCE = {
|
|
|
892
853
|
return findings;
|
|
893
854
|
},
|
|
894
855
|
};
|
|
895
|
-
|
|
896
|
-
|
|
856
|
+
// Credential detection covers two surfaces:
|
|
857
|
+
//
|
|
858
|
+
// 1. KEY shape — identifier names that conventionally hold secrets,
|
|
859
|
+
// followed by `=` or `:` (covers MCP-tool kwargs, HTTP headers like
|
|
860
|
+
// `Authorization: Bearer ...`, connector config k:v pairs).
|
|
861
|
+
//
|
|
862
|
+
// 2. VALUE shape — recognizable token prefixes/structures (Bearer
|
|
863
|
+
// tokens, OpenAI `sk-` keys, GitHub `ghp_` tokens, JWT `eyJ.X.Y`).
|
|
864
|
+
// Catches credentials in unnamed positions (e.g., literal Bearer
|
|
865
|
+
// token in a shell command body).
|
|
866
|
+
//
|
|
867
|
+
// Tier-2 (warning) — broader patterns mean some false positives are
|
|
868
|
+
// expected. False positives are noisy; false negatives are dangerous.
|
|
869
|
+
const CREDENTIAL_KEY_PATTERN = /\b(api[_-]?key|token|secret|password|passwd|pwd|auth(?:[_-]?token)?|access[_-]?token|bearer|client[_-]?secret|private[_-]?key|signing[_-]?key|refresh[_-]?token|connection[_-]?string|vault[_-]?token|db[_-]?password)\s*[:=]/i;
|
|
870
|
+
const CREDENTIAL_VALUE_PATTERN = /(?:Bearer\s+[A-Za-z0-9_.~+/=-]{20,}|\bsk-[A-Za-z0-9_-]{20,}|\bghp_[A-Za-z0-9]{20,}|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/;
|
|
871
|
+
function findsCredential(text) {
|
|
872
|
+
return CREDENTIAL_KEY_PATTERN.test(text) || CREDENTIAL_VALUE_PATTERN.test(text);
|
|
873
|
+
}
|
|
897
874
|
const CREDENTIAL_IN_ARGS = {
|
|
898
875
|
id: "credential-in-args",
|
|
899
|
-
severity: "
|
|
900
|
-
description: "
|
|
901
|
-
remediation: "Move credentials to per-connector config (env vars, mounted secrets). Skill
|
|
876
|
+
severity: "warning",
|
|
877
|
+
description: "An op or `# Vars:` default appears to carry credential-like content. Credentials don't belong in skill source.",
|
|
878
|
+
remediation: "Move credentials to per-connector config (env vars in connectors.json, mounted secrets). Skill source should reference operator-managed values via `# Requires: user-var:NAME -> VAR`, not embed them.",
|
|
902
879
|
check: (ctx) => {
|
|
903
880
|
const findings = [];
|
|
881
|
+
const reported = new Set();
|
|
882
|
+
const emit = (where, snippet, block) => {
|
|
883
|
+
const key = `${where}:${snippet.slice(0, 60)}`;
|
|
884
|
+
if (reported.has(key))
|
|
885
|
+
return;
|
|
886
|
+
reported.add(key);
|
|
887
|
+
findings.push({
|
|
888
|
+
rule: "credential-in-args",
|
|
889
|
+
severity: "warning",
|
|
890
|
+
message: `${where}: credential-like content detected ('${snippet.slice(0, 60).replace(/\n/g, " ")}${snippet.length > 60 ? "..." : ""}').`,
|
|
891
|
+
...(block !== undefined ? { block } : {}),
|
|
892
|
+
});
|
|
893
|
+
};
|
|
894
|
+
// # Vars: default values — author-written, often where adopters paste secrets by mistake.
|
|
895
|
+
for (const v of ctx.parsed.vars) {
|
|
896
|
+
if (v.default !== undefined && findsCredential(v.default)) {
|
|
897
|
+
emit(`# Vars: ${v.name} default`, v.default);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// Op bodies + value-bearing fields across all op kinds.
|
|
904
901
|
for (const [targetName, target] of ctx.parsed.targets) {
|
|
905
902
|
walkOps(target.ops, (op) => {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
block: targetName,
|
|
914
|
-
});
|
|
903
|
+
// Op body — covers $, shell, emit, $set body, foreach list, etc.
|
|
904
|
+
if (op.body !== undefined && findsCredential(op.body)) {
|
|
905
|
+
emit(`Op '${op.kind}' in target '${targetName}'`, op.body, targetName);
|
|
906
|
+
}
|
|
907
|
+
// Mutation-statement value (setValue holds the RHS of $set / $append).
|
|
908
|
+
if (op.setValue !== undefined && findsCredential(op.setValue)) {
|
|
909
|
+
emit(`'${op.kind}' value in target '${targetName}'`, op.setValue, targetName);
|
|
915
910
|
}
|
|
916
911
|
});
|
|
917
912
|
}
|
|
@@ -1013,11 +1008,11 @@ const MISSING_SKILLSTORE_FOR_DATA_REF = {
|
|
|
1013
1008
|
const findings = [];
|
|
1014
1009
|
for (const [targetName, target] of ctx.parsed.targets) {
|
|
1015
1010
|
for (const op of target.ops) {
|
|
1016
|
-
if (op.kind === "
|
|
1011
|
+
if (op.kind === "inline") {
|
|
1017
1012
|
findings.push({
|
|
1018
1013
|
rule: "missing-skillstore-for-data-ref",
|
|
1019
1014
|
severity: "error",
|
|
1020
|
-
message: `Skill references skill '${op.ampParams?.skillName ?? "(unknown)"}' via
|
|
1015
|
+
message: `Skill references skill '${op.ampParams?.skillName ?? "(unknown)"}' via \`inline(...)\`, but lint was invoked without a SkillStore (call site: ${ctx.callSite}). Data-skill inlining will silently skip; the \`inline\` op will survive into the runtime and error.`,
|
|
1021
1016
|
block: targetName,
|
|
1022
1017
|
extras: { call_site: ctx.callSite },
|
|
1023
1018
|
});
|
|
@@ -1088,7 +1083,7 @@ const UNSAFE_SHELL_AMBIGUOUS_SUBST = {
|
|
|
1088
1083
|
for (const [targetName, target] of ctx.parsed.targets) {
|
|
1089
1084
|
const reported = new Set();
|
|
1090
1085
|
walkOps(target.ops, (op) => {
|
|
1091
|
-
if (op.kind !== "
|
|
1086
|
+
if (op.kind !== "shell" || op.policy !== "unsafe")
|
|
1092
1087
|
return;
|
|
1093
1088
|
const re = new RegExp(REF_RE.source, "g");
|
|
1094
1089
|
let m;
|
|
@@ -1128,6 +1123,81 @@ const UNSAFE_SHELL_AMBIGUOUS_SUBST = {
|
|
|
1128
1123
|
return findings;
|
|
1129
1124
|
},
|
|
1130
1125
|
};
|
|
1126
|
+
// Sibling to unsafe-shell-ambiguous-subst: that rule catches refs that
|
|
1127
|
+
// AREN'T declared (potential bash command-sub collision); this rule
|
|
1128
|
+
// catches refs that ARE declared but are inlined raw into bash, missing
|
|
1129
|
+
// the `|shell` escape filter. Author intent is "substitute this variable"
|
|
1130
|
+
// but they haven't told the runtime to bash-escape it — variable values
|
|
1131
|
+
// containing spaces or shell metacharacters break the command silently
|
|
1132
|
+
// or, worse, become injectable.
|
|
1133
|
+
const UNSAFE_SHELL_UNESCAPED_SUBST = {
|
|
1134
|
+
id: "unsafe-shell-unescaped-subst",
|
|
1135
|
+
severity: "warning",
|
|
1136
|
+
description: "An `unsafe=true` shell op interpolates a declared variable into a bash command body without the `|shell` escape filter. Variable values containing whitespace or shell metacharacters break the command or become injectable.",
|
|
1137
|
+
remediation: "Add the `|shell` filter on every interpolation: `${VAR|shell}` produces a single bash-quoted token. The filter exists for exactly this case — it's the canonical escape for unsafe-shell substitution.",
|
|
1138
|
+
check: (ctx) => {
|
|
1139
|
+
const declared = new Set();
|
|
1140
|
+
for (const v of ctx.parsed.vars)
|
|
1141
|
+
declared.add(v.name);
|
|
1142
|
+
for (const r of ctx.parsed.requires)
|
|
1143
|
+
declared.add(r.target);
|
|
1144
|
+
for (const target of ctx.parsed.targets.values()) {
|
|
1145
|
+
const collect = (op) => {
|
|
1146
|
+
if (op.setName !== undefined)
|
|
1147
|
+
declared.add(op.setName);
|
|
1148
|
+
if (op.outputVar !== undefined)
|
|
1149
|
+
declared.add(op.outputVar);
|
|
1150
|
+
if (op.foreachIter !== undefined)
|
|
1151
|
+
declared.add(op.foreachIter);
|
|
1152
|
+
};
|
|
1153
|
+
walkOps(target.ops, collect);
|
|
1154
|
+
if (target.elseBlock !== undefined)
|
|
1155
|
+
walkOps(target.elseBlock, collect);
|
|
1156
|
+
}
|
|
1157
|
+
const findings = [];
|
|
1158
|
+
// Match both `${VAR|filters}` and `$(VAR|filters)` forms. Capture the
|
|
1159
|
+
// var name + the filter chain. Single `$` only — `$$(...)` is the
|
|
1160
|
+
// bash-literal escape, not a skillscript substitution.
|
|
1161
|
+
const REF_RE = /(?<!\$)\$(?:\{([^|}\s]+)((?:\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*)\}|\(([^|)\s]+)((?:\|\s*[A-Za-z_]\w*(?:\s*:\s*"[^"]*")?)*)\))/g;
|
|
1162
|
+
for (const [targetName, target] of ctx.parsed.targets) {
|
|
1163
|
+
const reported = new Set();
|
|
1164
|
+
walkOps(target.ops, (op) => {
|
|
1165
|
+
if (op.kind !== "shell" || op.policy !== "unsafe")
|
|
1166
|
+
return;
|
|
1167
|
+
const re = new RegExp(REF_RE.source, "g");
|
|
1168
|
+
let m;
|
|
1169
|
+
while ((m = re.exec(op.body)) !== null) {
|
|
1170
|
+
const varName = (m[1] ?? m[3]);
|
|
1171
|
+
const chain = (m[2] ?? m[4]) ?? "";
|
|
1172
|
+
// Skip dotted refs (structured access — author's responsibility).
|
|
1173
|
+
if (varName.includes("."))
|
|
1174
|
+
continue;
|
|
1175
|
+
// Skip ambient refs — runtime-injected, author can't filter them.
|
|
1176
|
+
if (AMBIENT_VARS.includes(varName))
|
|
1177
|
+
continue;
|
|
1178
|
+
// Skip refs whose ROOT identifier isn't declared (those fire as
|
|
1179
|
+
// `unsafe-shell-ambiguous-subst` or `undeclared-var` instead).
|
|
1180
|
+
if (!declared.has(varName))
|
|
1181
|
+
continue;
|
|
1182
|
+
// The cure: a `|shell` filter in the chain.
|
|
1183
|
+
if (/\|\s*shell\b/.test(chain))
|
|
1184
|
+
continue;
|
|
1185
|
+
if (reported.has(varName))
|
|
1186
|
+
continue;
|
|
1187
|
+
reported.add(varName);
|
|
1188
|
+
findings.push({
|
|
1189
|
+
rule: "unsafe-shell-unescaped-subst",
|
|
1190
|
+
severity: "warning",
|
|
1191
|
+
message: `\`\${${varName}}\` interpolated into \`shell(command=..., unsafe=true)\` body in target '${targetName}' without the \`|shell\` escape filter. Variable values with spaces or shell metacharacters will break the command or become injectable. Rewrite as \`\${${varName}|shell}\`.`,
|
|
1192
|
+
block: targetName,
|
|
1193
|
+
extras: { var_name: varName },
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
return findings;
|
|
1199
|
+
},
|
|
1200
|
+
};
|
|
1131
1201
|
const UNSAFE_SHELL_OP = {
|
|
1132
1202
|
id: "unsafe-shell-op",
|
|
1133
1203
|
severity: "warning",
|
|
@@ -1137,7 +1207,7 @@ const UNSAFE_SHELL_OP = {
|
|
|
1137
1207
|
const findings = [];
|
|
1138
1208
|
for (const [targetName, target] of ctx.parsed.targets) {
|
|
1139
1209
|
walkOps(target.ops, (op) => {
|
|
1140
|
-
if (op.kind === "
|
|
1210
|
+
if (op.kind === "shell" && op.policy === "unsafe") {
|
|
1141
1211
|
findings.push({
|
|
1142
1212
|
rule: "unsafe-shell-op",
|
|
1143
1213
|
severity: "warning",
|
|
@@ -1173,7 +1243,7 @@ const UNSAFE_SHELL_DISABLED = {
|
|
|
1173
1243
|
const findings = [];
|
|
1174
1244
|
for (const [targetName, target] of ctx.parsed.targets) {
|
|
1175
1245
|
walkOps(target.ops, (op) => {
|
|
1176
|
-
if (op.kind === "
|
|
1246
|
+
if (op.kind === "shell" && op.policy === "unsafe") {
|
|
1177
1247
|
findings.push({
|
|
1178
1248
|
rule: "unsafe-shell-disabled",
|
|
1179
1249
|
severity: "error",
|
|
@@ -1202,8 +1272,8 @@ const UNSAFE_SHELL_DISABLED = {
|
|
|
1202
1272
|
const UNCONFIRMED_MUTATION = {
|
|
1203
1273
|
id: "unconfirmed-mutation",
|
|
1204
1274
|
severity: "warning",
|
|
1205
|
-
description: "A mutation-class op runs without author authorization. Mutation classes: `$ tool` with mutating-name shape (write/update/delete/...); `$ data_write` MCP dispatch; `file_write(...)` function-call op. Silent when the skill declares `# Autonomous: true`
|
|
1206
|
-
remediation: "
|
|
1275
|
+
description: "A mutation-class op runs without author authorization. Mutation classes: `$ tool` with mutating-name shape (write/update/delete/...); `$ data_write` MCP dispatch; `file_write(...)` function-call op. Silent when the skill declares `# Autonomous: true` or when the op carries `approved=\"reason\"` per-op authorization.",
|
|
1276
|
+
remediation: "Two ways to authorize: (1) add `# Autonomous: true` at the skill header for cron/agent-fired skills; (2) pass `approved=\"reason\"` kwarg on the mutation op itself (any non-empty string; presence is what matters, value not parsed semantically).",
|
|
1207
1277
|
check: (ctx) => {
|
|
1208
1278
|
const skillAutonomous = ctx.parsed.autonomous === true;
|
|
1209
1279
|
// v0.4.2 — `# Autonomous: true` skills are unattended by design;
|
|
@@ -1214,12 +1284,8 @@ const UNCONFIRMED_MUTATION = {
|
|
|
1214
1284
|
return [];
|
|
1215
1285
|
const findings = [];
|
|
1216
1286
|
for (const [targetName, target] of ctx.parsed.targets) {
|
|
1217
|
-
const authState = { skillAutonomous
|
|
1287
|
+
const authState = { skillAutonomous };
|
|
1218
1288
|
for (const op of target.ops) {
|
|
1219
|
-
// v0.7.0+: ask(prompt=...) parses to kind "??" in the AST, so the
|
|
1220
|
-
// same check captures both legacy `??` and canonical `ask()`.
|
|
1221
|
-
if (op.kind === "??")
|
|
1222
|
-
authState.sawConfirm = true;
|
|
1223
1289
|
if (authorizationGranted(op, authState))
|
|
1224
1290
|
continue;
|
|
1225
1291
|
const classification = classifyMutation(op);
|
|
@@ -1229,7 +1295,7 @@ const UNCONFIRMED_MUTATION = {
|
|
|
1229
1295
|
findings.push({
|
|
1230
1296
|
rule: "unconfirmed-mutation",
|
|
1231
1297
|
severity: "warning",
|
|
1232
|
-
message: `\`file_write(path="${classification.detail}")\` in target '${targetName}' is a mutation op without authorization. Add \`approved="..."\` kwarg
|
|
1298
|
+
message: `\`file_write(path="${classification.detail}")\` in target '${targetName}' is a mutation op without authorization. Add \`approved="..."\` kwarg or declare \`# Autonomous: true\`.`,
|
|
1233
1299
|
block: targetName,
|
|
1234
1300
|
extras: { op_kind: "file_write", path: classification.detail },
|
|
1235
1301
|
});
|
|
@@ -1238,7 +1304,7 @@ const UNCONFIRMED_MUTATION = {
|
|
|
1238
1304
|
findings.push({
|
|
1239
1305
|
rule: "unconfirmed-mutation",
|
|
1240
1306
|
severity: "warning",
|
|
1241
|
-
message: `\`$\` op in target '${targetName}' invokes '${classification.detail}' (mutating shape) without authorization. Add \`approved="..."\` kwarg
|
|
1307
|
+
message: `\`$\` op in target '${targetName}' invokes '${classification.detail}' (mutating shape) without authorization. Add \`approved="..."\` kwarg or declare \`# Autonomous: true\`.`,
|
|
1242
1308
|
block: targetName,
|
|
1243
1309
|
extras: { tool_name: classification.detail },
|
|
1244
1310
|
});
|
|
@@ -1248,41 +1314,6 @@ const UNCONFIRMED_MUTATION = {
|
|
|
1248
1314
|
return findings;
|
|
1249
1315
|
},
|
|
1250
1316
|
};
|
|
1251
|
-
const MODEL_CONTENTION = {
|
|
1252
|
-
id: "model-contention",
|
|
1253
|
-
severity: "warning",
|
|
1254
|
-
description: "Skill body has a `$` op dispatching async batch work on a model + a downstream `~ model=X` synchronous call to the same model. The runtime serializes per-model; the sync call queues behind the batch.",
|
|
1255
|
-
remediation: "Use distinct models for async vs sync work: e.g., `gemma2` for batch + `qwen` for the interactive verdict. See ERD §3 model selection convention.",
|
|
1256
|
-
check: (ctx) => {
|
|
1257
|
-
const findings = [];
|
|
1258
|
-
// Heuristic: collect ~ op model names per target. Flag if any $ op
|
|
1259
|
-
// in the same target dispatches a batch-classification-shaped tool
|
|
1260
|
-
// (name contains "olsen", "scan", "batch", "classify"). Conservative.
|
|
1261
|
-
for (const [targetName, target] of ctx.parsed.targets) {
|
|
1262
|
-
const syncModels = new Set();
|
|
1263
|
-
walkOps(target.ops, (op) => {
|
|
1264
|
-
if (op.kind === "~" && op.localModelParams?.model)
|
|
1265
|
-
syncModels.add(op.localModelParams.model);
|
|
1266
|
-
});
|
|
1267
|
-
if (syncModels.size === 0)
|
|
1268
|
-
return findings;
|
|
1269
|
-
walkOps(target.ops, (op) => {
|
|
1270
|
-
if (op.kind !== "$")
|
|
1271
|
-
return;
|
|
1272
|
-
const toolName = op.body.split(/\s+/)[0] ?? "";
|
|
1273
|
-
if (/scan|batch|classify|atomize/i.test(toolName)) {
|
|
1274
|
-
findings.push({
|
|
1275
|
-
rule: "model-contention",
|
|
1276
|
-
severity: "warning",
|
|
1277
|
-
message: `Target '${targetName}' dispatches batch work via '${toolName}' AND uses sync \`~ model=...\` — possible model contention on the same backend.`,
|
|
1278
|
-
block: targetName,
|
|
1279
|
-
});
|
|
1280
|
-
}
|
|
1281
|
-
});
|
|
1282
|
-
}
|
|
1283
|
-
return findings;
|
|
1284
|
-
},
|
|
1285
|
-
};
|
|
1286
1317
|
const DRAFT_WITH_TRIGGER = {
|
|
1287
1318
|
id: "draft-with-trigger",
|
|
1288
1319
|
severity: "warning",
|
|
@@ -1523,7 +1554,7 @@ const OUTPUT_AGENT_TARGET_NO_EMIT = {
|
|
|
1523
1554
|
return findings;
|
|
1524
1555
|
let hasEmit = false;
|
|
1525
1556
|
for (const [, target] of ctx.parsed.targets) {
|
|
1526
|
-
walkOps(target.ops, (op) => { if (op.kind === "
|
|
1557
|
+
walkOps(target.ops, (op) => { if (op.kind === "emit")
|
|
1527
1558
|
hasEmit = true; });
|
|
1528
1559
|
if (hasEmit)
|
|
1529
1560
|
break;
|
|
@@ -2055,12 +2086,8 @@ function buildBindingOrigins(parsed) {
|
|
|
2055
2086
|
if (op.outputVar !== undefined) {
|
|
2056
2087
|
if (op.kind === "$")
|
|
2057
2088
|
origins.set(op.outputVar, { kind: "op-output", op: "$" });
|
|
2058
|
-
else if (op.kind === "
|
|
2059
|
-
origins.set(op.outputVar, { kind: "op-output", op: "
|
|
2060
|
-
else if (op.kind === ">")
|
|
2061
|
-
origins.set(op.outputVar, { kind: "op-output", op: ">" });
|
|
2062
|
-
else if (op.kind === "@")
|
|
2063
|
-
origins.set(op.outputVar, { kind: "op-output", op: "@" });
|
|
2089
|
+
else if (op.kind === "shell")
|
|
2090
|
+
origins.set(op.outputVar, { kind: "op-output", op: "shell" });
|
|
2064
2091
|
}
|
|
2065
2092
|
if (op.kind === "foreach" && op.foreachIter !== undefined) {
|
|
2066
2093
|
origins.set(op.foreachIter, { kind: "foreach-iter" });
|
|
@@ -2136,14 +2163,13 @@ const UNQUOTED_SUBSTITUTION_IN_KWARG_VALUE = {
|
|
|
2136
2163
|
});
|
|
2137
2164
|
}
|
|
2138
2165
|
}
|
|
2139
|
-
else if (op.kind === "
|
|
2140
|
-
//
|
|
2141
|
-
//
|
|
2142
|
-
//
|
|
2143
|
-
//
|
|
2166
|
+
else if (op.kind === "shell") {
|
|
2167
|
+
// Tokenize the shell body the same way the runtime would
|
|
2168
|
+
// (whitespace-separated, quotes respected), then flag any token
|
|
2169
|
+
// that is a bare unquoted substitution. Quoted tokens
|
|
2170
|
+
// (`"${VAR}"` / `'${VAR}'`) are safe.
|
|
2144
2171
|
const tokens = tokenizeKeywordArgs(op.body);
|
|
2145
2172
|
for (const tok of tokens) {
|
|
2146
|
-
// Skip quoted tokens — the quotes protect against whitespace split.
|
|
2147
2173
|
if ((tok.startsWith('"') && tok.endsWith('"')) || (tok.startsWith("'") && tok.endsWith("'")))
|
|
2148
2174
|
continue;
|
|
2149
2175
|
if (!(tok.startsWith("$(") || tok.startsWith("${")))
|
|
@@ -2156,16 +2182,16 @@ const UNQUOTED_SUBSTITUTION_IN_KWARG_VALUE = {
|
|
|
2156
2182
|
const origin = origins.get(rootVar);
|
|
2157
2183
|
if (!isOriginSuspect(origin))
|
|
2158
2184
|
continue;
|
|
2159
|
-
const dedupKey = `${targetName}
|
|
2185
|
+
const dedupKey = `${targetName}:shell:${varName}`;
|
|
2160
2186
|
if (reported.has(dedupKey))
|
|
2161
2187
|
continue;
|
|
2162
2188
|
reported.add(dedupKey);
|
|
2163
2189
|
findings.push({
|
|
2164
2190
|
rule: "unquoted-substitution-in-kwarg-value",
|
|
2165
2191
|
severity: "warning",
|
|
2166
|
-
message:
|
|
2192
|
+
message: `\`shell(command="... \${${varName}} ...")\` in target '${targetName}': unquoted substitution. ${describeOriginRisk(origin)} Wrap as \`"\${${varName}}"\` to prevent silent word-splitting if the value contains whitespace.`,
|
|
2167
2193
|
block: targetName,
|
|
2168
|
-
extras: { var_name: varName, origin: origin.kind, op: "
|
|
2194
|
+
extras: { var_name: varName, origin: origin.kind, op: "shell" },
|
|
2169
2195
|
});
|
|
2170
2196
|
}
|
|
2171
2197
|
}
|
|
@@ -2188,86 +2214,6 @@ function describeOriginRisk(origin) {
|
|
|
2188
2214
|
return `Variable is a \`foreach\` iterator — element type unknown statically.`;
|
|
2189
2215
|
}
|
|
2190
2216
|
}
|
|
2191
|
-
/**
|
|
2192
|
-
* v0.7.1 — tier-2 visibility nudge for legacy symbol-form ops (`~`, `>`,
|
|
2193
|
-
* `@`, `!`, `??`, `&`). The parser still accepts these during the v0.7.x
|
|
2194
|
-
* grace period; the runtime dispatches them as before. This rule surfaces
|
|
2195
|
-
* the canonical replacement so authors editing skills see the migration
|
|
2196
|
-
* path. Tier-1 promotion (refuse-to-compile) lands in v0.8 or v0.9 once
|
|
2197
|
-
* the adopter ecosystem confirms migration is settled.
|
|
2198
|
-
*
|
|
2199
|
-
* The `$ tool` op is NOT flagged — that shape is canonical (MCP dispatch
|
|
2200
|
-
* marker). Only the 6 symbol ops that became function-call ops in v0.7.0.
|
|
2201
|
-
*/
|
|
2202
|
-
const DEPRECATED_SYMBOL_OP_REPLACEMENT = {
|
|
2203
|
-
// v0.7.2: bridge classes ship default-wired so `$ llm` / `$ data_read` work
|
|
2204
|
-
// out of the box in default deployments. Suggestions are load-bearing
|
|
2205
|
-
// (no more "(or your wired connector name)" caveat).
|
|
2206
|
-
"~": "$ llm prompt=\"...\" [maxTokens=N] [model=\"...\"] -> R",
|
|
2207
|
-
">": "$ data_read mode=\"fts|semantic|rerank\" query=\"...\" limit=N -> R",
|
|
2208
|
-
"@": "shell(command=\"...\") [-> R]",
|
|
2209
|
-
"!": "emit(text=\"...\")",
|
|
2210
|
-
"??": "ask(prompt=\"...\") -> R",
|
|
2211
|
-
"&": "inline(skill=\"...\") (or execute_skill(skill_name=\"...\", ...) -> R for procedural composition)",
|
|
2212
|
-
};
|
|
2213
|
-
const DEPRECATED_SYMBOL_OP = {
|
|
2214
|
-
id: "deprecated-symbol-op",
|
|
2215
|
-
severity: "warning",
|
|
2216
|
-
description: "An op uses the legacy symbol form deprecated in v0.7.0.",
|
|
2217
|
-
remediation: "Rewrite to the canonical v0.7.0 form (see message). All legacy ops continue to compile during the grace period; tier-1 promotion (refuse-to-compile) lands in v0.8/v0.9. See CHANGELOG.md `## 0.7.0 — Migration` for the full rewrite rules.",
|
|
2218
|
-
check: (ctx) => {
|
|
2219
|
-
const findings = [];
|
|
2220
|
-
for (const [targetName, target] of ctx.parsed.targets) {
|
|
2221
|
-
const reported = new Set();
|
|
2222
|
-
walkOps(target.ops, (op) => {
|
|
2223
|
-
const replacement = DEPRECATED_SYMBOL_OP_REPLACEMENT[op.kind];
|
|
2224
|
-
if (replacement === undefined)
|
|
2225
|
-
return;
|
|
2226
|
-
// v0.7.1: skip ops authored in canonical function-call form. The
|
|
2227
|
-
// parser collapses both `! x` and `emit(text="x")` to kind "!",
|
|
2228
|
-
// so without the sourceForm marker the lint would fire on
|
|
2229
|
-
// canonical code. Source-form marker preserves which surface
|
|
2230
|
-
// the author wrote.
|
|
2231
|
-
if (op.sourceForm === "function-call")
|
|
2232
|
-
return;
|
|
2233
|
-
// Dedupe per-kind-per-target — one nudge per legacy op type per
|
|
2234
|
-
// target is plenty; further occurrences don't add signal.
|
|
2235
|
-
const key = `${targetName}:${op.kind}`;
|
|
2236
|
-
if (reported.has(key))
|
|
2237
|
-
return;
|
|
2238
|
-
reported.add(key);
|
|
2239
|
-
findings.push({
|
|
2240
|
-
rule: "deprecated-symbol-op",
|
|
2241
|
-
severity: "warning",
|
|
2242
|
-
message: `Op '${op.kind}' in target '${targetName}' is deprecated in v0.7.0. Rewrite as: \`${replacement}\``,
|
|
2243
|
-
block: targetName,
|
|
2244
|
-
extras: { legacy_op: op.kind, canonical_replacement: replacement },
|
|
2245
|
-
});
|
|
2246
|
-
});
|
|
2247
|
-
if (target.elseBlock !== undefined) {
|
|
2248
|
-
walkOps(target.elseBlock, (op) => {
|
|
2249
|
-
const replacement = DEPRECATED_SYMBOL_OP_REPLACEMENT[op.kind];
|
|
2250
|
-
if (replacement === undefined)
|
|
2251
|
-
return;
|
|
2252
|
-
if (op.sourceForm === "function-call")
|
|
2253
|
-
return;
|
|
2254
|
-
const key = `${targetName}:else:${op.kind}`;
|
|
2255
|
-
if (reported.has(key))
|
|
2256
|
-
return;
|
|
2257
|
-
reported.add(key);
|
|
2258
|
-
findings.push({
|
|
2259
|
-
rule: "deprecated-symbol-op",
|
|
2260
|
-
severity: "warning",
|
|
2261
|
-
message: `Op '${op.kind}' in target '${targetName}' (else block) is deprecated in v0.7.0. Rewrite as: \`${replacement}\``,
|
|
2262
|
-
block: targetName,
|
|
2263
|
-
extras: { legacy_op: op.kind, canonical_replacement: replacement },
|
|
2264
|
-
});
|
|
2265
|
-
});
|
|
2266
|
-
}
|
|
2267
|
-
}
|
|
2268
|
-
return findings;
|
|
2269
|
-
},
|
|
2270
|
-
};
|
|
2271
2217
|
/**
|
|
2272
2218
|
* v0.7.1 — tier-2 visibility nudge for legacy `$(VAR)` substitution form.
|
|
2273
2219
|
* Canonical v0.7.0+ form is `${VAR}`. Parser/runtime accept both during
|
|
@@ -2381,7 +2327,6 @@ const RULES = [
|
|
|
2381
2327
|
RESERVED_KEYWORD,
|
|
2382
2328
|
UNKNOWN_SKILL_REFERENCE,
|
|
2383
2329
|
UNKNOWN_TEMPLATE_REFERENCE,
|
|
2384
|
-
UNKNOWN_RETRIEVAL_ARG,
|
|
2385
2330
|
UNKNOWN_CONNECTOR,
|
|
2386
2331
|
UNKNOWN_CONNECTOR_CLASS,
|
|
2387
2332
|
UNWIRED_PRIMARY_CONNECTOR,
|
|
@@ -2399,14 +2344,13 @@ const RULES = [
|
|
|
2399
2344
|
MISSING_SKILLSTORE_FOR_DATA_REF,
|
|
2400
2345
|
// Tier-2 (warning)
|
|
2401
2346
|
DEPRECATED_QUESTION,
|
|
2402
|
-
DEPRECATED_SYMBOL_OP,
|
|
2403
2347
|
DEPRECATED_SUBSTITUTION_SHAPE,
|
|
2404
2348
|
UNSAFE_SHELL_AMBIGUOUS_SUBST,
|
|
2349
|
+
UNSAFE_SHELL_UNESCAPED_SUBST,
|
|
2405
2350
|
UNSAFE_SHELL_OP,
|
|
2406
2351
|
UNSAFE_SHELL_DISABLED,
|
|
2407
2352
|
UNCONFIRMED_MUTATION,
|
|
2408
2353
|
UNQUOTED_SUBSTITUTION_IN_KWARG_VALUE,
|
|
2409
|
-
MODEL_CONTENTION,
|
|
2410
2354
|
DRAFT_WITH_TRIGGER,
|
|
2411
2355
|
REFERENCE_TO_DISABLED_SKILL,
|
|
2412
2356
|
UNUSED_AUGMENTING_HEADER,
|
|
@@ -2457,9 +2401,9 @@ function collectAmpRefsFromOps(ops) {
|
|
|
2457
2401
|
out.push({ name, via });
|
|
2458
2402
|
};
|
|
2459
2403
|
walkOps(ops, (op) => {
|
|
2460
|
-
if (op.kind === "
|
|
2461
|
-
emit(op.ampParams.skillName, "
|
|
2462
|
-
//
|
|
2404
|
+
if (op.kind === "inline" && op.ampParams !== undefined)
|
|
2405
|
+
emit(op.ampParams.skillName, "inline");
|
|
2406
|
+
// `$ execute_skill` is also a composition primitive.
|
|
2463
2407
|
if (op.kind === "$" && /^execute_skill\b/.test(op.body)) {
|
|
2464
2408
|
// v0.15.2 — accept either `name` or `skill_name` (back-compat alias).
|
|
2465
2409
|
const m = /\b(?:skill_name|name)\s*=\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\w-]*))/.exec(op.body);
|
|
@@ -2520,11 +2464,6 @@ function extractVarRefsWithFilter(op) {
|
|
|
2520
2464
|
}
|
|
2521
2465
|
function collectOpText(op) {
|
|
2522
2466
|
let text = op.body;
|
|
2523
|
-
if (op.retrievalParams !== undefined) {
|
|
2524
|
-
text += " " + op.retrievalParams.query + " " + Object.values(op.retrievalParams.extra).join(" ");
|
|
2525
|
-
}
|
|
2526
|
-
if (op.localModelParams !== undefined)
|
|
2527
|
-
text += " " + op.localModelParams.prompt;
|
|
2528
2467
|
if (op.setValue !== undefined)
|
|
2529
2468
|
text += " " + op.setValue;
|
|
2530
2469
|
if (op.foreachList !== undefined)
|