@sponsio/sdk 0.1.0 → 0.2.0-alpha.8

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.
Files changed (123) hide show
  1. package/contracts/benchmark/agentdojo.yaml +551 -0
  2. package/contracts/benchmark/odcv_bench.yaml +393 -0
  3. package/contracts/benchmark/redcode_exec.yaml +488 -0
  4. package/contracts/benchmark/swebench.yaml +176 -0
  5. package/contracts/benchmark/tau2_bench.yaml +984 -0
  6. package/contracts/capability/destructive.yaml +213 -0
  7. package/contracts/capability/factual-claims.yaml +79 -0
  8. package/contracts/capability/host-config-integrity.yaml +42 -9
  9. package/contracts/capability/self-modify.yaml +32 -1
  10. package/contracts/capability/shell.yaml +19 -10
  11. package/contracts/incident/claude-code-secret-bypass.yaml +48 -16
  12. package/contracts/incident/cursor-railway-wipe.yaml +60 -4
  13. package/contracts/incident/openclaw.yaml +9 -62
  14. package/contracts/premium/.gitkeep +5 -6
  15. package/dist/cli/cli.js +32 -0
  16. package/dist/cli/cli.js.map +1 -1
  17. package/dist/cli/packs.d.ts.map +1 -1
  18. package/dist/cli/packs.js +14 -8
  19. package/dist/cli/packs.js.map +1 -1
  20. package/dist/cli/patterns.d.ts +5 -4
  21. package/dist/cli/patterns.d.ts.map +1 -1
  22. package/dist/cli/patterns.js +11 -10
  23. package/dist/cli/patterns.js.map +1 -1
  24. package/dist/cli/prompt.js +5 -5
  25. package/dist/cli/prompt.js.map +1 -1
  26. package/dist/cli/scan.js +2 -2
  27. package/dist/cli/skill.d.ts +1 -1
  28. package/dist/cli/skill.js +1 -1
  29. package/dist/cli/validate.js +5 -5
  30. package/dist/cli/validate.js.map +1 -1
  31. package/dist/cloud/client.d.ts +76 -0
  32. package/dist/cloud/client.d.ts.map +1 -0
  33. package/dist/cloud/client.js +198 -0
  34. package/dist/cloud/client.js.map +1 -0
  35. package/dist/cloud/evidence.d.ts +108 -0
  36. package/dist/cloud/evidence.d.ts.map +1 -0
  37. package/dist/cloud/evidence.js +184 -0
  38. package/dist/cloud/evidence.js.map +1 -0
  39. package/dist/cloud/privacy.d.ts +67 -0
  40. package/dist/cloud/privacy.d.ts.map +1 -0
  41. package/dist/cloud/privacy.js +170 -0
  42. package/dist/cloud/privacy.js.map +1 -0
  43. package/dist/cloud/view-model.d.ts +49 -0
  44. package/dist/cloud/view-model.d.ts.map +1 -0
  45. package/dist/cloud/view-model.js +172 -0
  46. package/dist/cloud/view-model.js.map +1 -0
  47. package/dist/core/config-loader.d.ts.map +1 -1
  48. package/dist/core/config-loader.js +81 -9
  49. package/dist/core/config-loader.js.map +1 -1
  50. package/dist/core/evaluator.d.ts +11 -1
  51. package/dist/core/evaluator.d.ts.map +1 -1
  52. package/dist/core/evaluator.js +275 -13
  53. package/dist/core/evaluator.js.map +1 -1
  54. package/dist/core/formula.d.ts +76 -17
  55. package/dist/core/formula.d.ts.map +1 -1
  56. package/dist/core/formula.js +83 -1
  57. package/dist/core/formula.js.map +1 -1
  58. package/dist/core/grounding.d.ts +44 -8
  59. package/dist/core/grounding.d.ts.map +1 -1
  60. package/dist/core/grounding.js +197 -26
  61. package/dist/core/grounding.js.map +1 -1
  62. package/dist/core/nl-parser.d.ts +2 -2
  63. package/dist/core/nl-parser.d.ts.map +1 -1
  64. package/dist/core/nl-parser.js +617 -41
  65. package/dist/core/nl-parser.js.map +1 -1
  66. package/dist/core/pack-loader.d.ts.map +1 -1
  67. package/dist/core/pack-loader.js +14 -2
  68. package/dist/core/pack-loader.js.map +1 -1
  69. package/dist/core/parser.d.ts.map +1 -1
  70. package/dist/core/parser.js +30 -1
  71. package/dist/core/parser.js.map +1 -1
  72. package/dist/core/pattern-factory.d.ts.map +1 -1
  73. package/dist/core/pattern-factory.js +14 -1
  74. package/dist/core/pattern-factory.js.map +1 -1
  75. package/dist/core/patterns.d.ts +118 -1
  76. package/dist/core/patterns.d.ts.map +1 -1
  77. package/dist/core/patterns.js +189 -10
  78. package/dist/core/patterns.js.map +1 -1
  79. package/dist/core/session-log.d.ts.map +1 -1
  80. package/dist/core/session-log.js +20 -1
  81. package/dist/core/session-log.js.map +1 -1
  82. package/dist/core/sto.d.ts +21 -27
  83. package/dist/core/sto.d.ts.map +1 -1
  84. package/dist/core/sto.js +30 -33
  85. package/dist/core/sto.js.map +1 -1
  86. package/dist/core/tool-names.d.ts +59 -0
  87. package/dist/core/tool-names.d.ts.map +1 -0
  88. package/dist/core/tool-names.js +128 -0
  89. package/dist/core/tool-names.js.map +1 -0
  90. package/dist/index.d.ts +181 -32
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +415 -67
  93. package/dist/index.js.map +1 -1
  94. package/dist/integrations/claude-agent.d.ts +5 -0
  95. package/dist/integrations/claude-agent.d.ts.map +1 -1
  96. package/dist/integrations/claude-agent.js +42 -13
  97. package/dist/integrations/claude-agent.js.map +1 -1
  98. package/dist/integrations/google-adk.d.ts +5 -3
  99. package/dist/integrations/google-adk.d.ts.map +1 -1
  100. package/dist/integrations/google-adk.js +9 -5
  101. package/dist/integrations/google-adk.js.map +1 -1
  102. package/dist/integrations/langchain.d.ts +7 -0
  103. package/dist/integrations/langchain.d.ts.map +1 -1
  104. package/dist/integrations/langchain.js +21 -3
  105. package/dist/integrations/langchain.js.map +1 -1
  106. package/dist/integrations/openai-agents.d.ts +22 -10
  107. package/dist/integrations/openai-agents.d.ts.map +1 -1
  108. package/dist/integrations/openai-agents.js +55 -15
  109. package/dist/integrations/openai-agents.js.map +1 -1
  110. package/dist/integrations/openai.d.ts +11 -0
  111. package/dist/integrations/openai.d.ts.map +1 -1
  112. package/dist/integrations/openai.js +130 -48
  113. package/dist/integrations/openai.js.map +1 -1
  114. package/dist/integrations/vercel-ai.d.ts +20 -9
  115. package/dist/integrations/vercel-ai.d.ts.map +1 -1
  116. package/dist/integrations/vercel-ai.js +131 -32
  117. package/dist/integrations/vercel-ai.js.map +1 -1
  118. package/package.json +17 -9
  119. package/prompts/onboard.md +26 -17
  120. package/prompts/scan.md +3 -5
  121. package/skills/SKILL.md +13 -83
  122. package/contracts/core/llm_safety.yaml +0 -140
  123. package/prompts/refresh.md +0 -118
package/dist/index.js CHANGED
@@ -20,12 +20,16 @@
20
20
  * if (result.blocked) { ... }
21
21
  */
22
22
  import { evaluate } from "./core/evaluator.js";
23
- import { groundEvent, newGroundingState, collectContentAtoms, } from "./core/grounding.js";
23
+ import { groundEvent, newGroundingState, collectContentAtoms, validateContentPatterns, numericArg, toolMatches, } from "./core/grounding.js";
24
24
  import { parseNl } from "./core/nl-parser.js";
25
+ import { canonicalTool, toolAliases } from "./core/tool-names.js";
25
26
  import { loadSponsoConfig, } from "./core/config-loader.js";
26
27
  import { SessionLogger } from "./core/session-log.js";
27
- import { ContractCheckSpan, EnforcementSpan, GuaranteeSpan, SpanCollector, } from "./core/spans.js";
28
+ import { AgentTurnSpan, ContractCheckSpan, EnforcementSpan, GuaranteeSpan, SpanCollector, } from "./core/spans.js";
28
29
  import { renderSession } from "./render/session-view.js";
30
+ import { CloudClient, readProject } from "./cloud/client.js";
31
+ import * as privacy from "./cloud/privacy.js";
32
+ import { buildViewModel, newSessionId, } from "./cloud/view-model.js";
29
33
  // Re-exports
30
34
  export { evaluate } from "./core/evaluator.js";
31
35
  export * from "./core/formula.js";
@@ -37,6 +41,18 @@ export { loadSponsoConfig } from "./core/config-loader.js";
37
41
  export { SessionLogger, rotateSessions } from "./core/session-log.js";
38
42
  export { contract, ContractBuilder } from "./contract.js";
39
43
  export { parseScore, CloudFeatureError } from "./core/sto.js";
44
+ export { CloudClient, CloudError, readApiKey, readProject, baseUrl, } from "./cloud/client.js";
45
+ export { LEVELS as PRIVACY_LEVELS } from "./cloud/privacy.js";
46
+ export { buildViewModel, newSessionId } from "./cloud/view-model.js";
47
+ /** Actions after which the original tool call must NOT run. */
48
+ export const STOPPING_ACTIONS = new Set([
49
+ "blocked",
50
+ "redirected",
51
+ ]);
52
+ /** Parity with Python's ``is_stopping_action``. */
53
+ export function isStoppingAction(action) {
54
+ return STOPPING_ACTIONS.has(action);
55
+ }
40
56
  /**
41
57
  * Best-effort accessor for the agent-facing line. Prefers the
42
58
  * structured ``agentMsg`` field when populated; falls back to
@@ -54,13 +70,33 @@ export function selectAgentMessage(violations, fallback = "Contract violation")
54
70
  }
55
71
  return fallback;
56
72
  }
73
+ /**
74
+ * Fill the derived half of a verdict from the violations' actions. One
75
+ * place, so a new `DetAction` cannot be handled inconsistently across
76
+ * the several sites that build a `CheckResult`.
77
+ */
78
+ export function deriveVerdict(detViolations, stoViolations = []) {
79
+ const acted = detViolations.filter((v) => v.action !== undefined);
80
+ const redirect = acted.find((v) => v.action === "redirected");
81
+ return {
82
+ blocked: acted.some((v) => v.action === "blocked"),
83
+ escalated: acted.some((v) => v.action === "escalated"),
84
+ redirected: redirect !== undefined,
85
+ ...(redirect?.redirectedTo !== undefined
86
+ ? { redirectedTo: redirect.redirectedTo }
87
+ : {}),
88
+ stopOriginal: acted.some((v) => isStoppingAction(v.action)),
89
+ allViolations: [...detViolations, ...stoViolations],
90
+ needsRetry: false,
91
+ };
92
+ }
57
93
  export class Sponsio {
58
94
  agentId;
59
95
  mode;
60
96
  _contracts;
61
- // Sto state. Always empty/null in OSS — the constructor rejects yaml
62
- // sto contracts and inline `judge:` at config load. Kept as
63
- // schema-stable surface for Cloud-side composition / wrapping.
97
+ // Sto state. Always empty/null in this build, the constructor
98
+ // rejects yaml sto contracts and inline `judge:` at config load.
99
+ // Kept as schema-stable surface for external composition / wrapping.
64
100
  _stoContracts;
65
101
  _stoContext;
66
102
  _trace;
@@ -69,6 +105,14 @@ export class Sponsio {
69
105
  _violations;
70
106
  _logger;
71
107
  _turnSpans = [];
108
+ // The same turns with the violations they produced, which the span
109
+ // tree does not carry in a form the cloud can read: it records that a
110
+ // contract was violated, not which pattern and arguments, and the
111
+ // cloud groups findings by exactly those.
112
+ _runSpans = [];
113
+ _sessionId = newSessionId();
114
+ _startedAt = Date.now();
115
+ _rulebookStamp;
72
116
  constructor(options = {}) {
73
117
  this.agentId = options.agentId ?? "agent";
74
118
  this._trace = [];
@@ -97,6 +141,13 @@ export class Sponsio {
97
141
  // ── Resolve mode: env > ctor > yaml > default ────────────────────
98
142
  this.mode = resolveMode(options.mode, yamlMode);
99
143
  // ── Parse NL strings into det formulas ───────────────────────────
144
+ // A contract that does not parse is a rule that is not there. In
145
+ // observe mode that is a warning, because the point of observe is to
146
+ // keep running and surface what would fire. In enforce it is fatal:
147
+ // the alternative is an agent that reports success while enforcing a
148
+ // book with holes in it, and the operator has no way to know which
149
+ // holes. Python raises here; TypeScript logged one line and ran on.
150
+ const unparsed = [];
100
151
  for (const c of sources) {
101
152
  if (typeof c === "string") {
102
153
  const parsed = parseNl(c);
@@ -104,46 +155,172 @@ export class Sponsio {
104
155
  this._contracts.push(parsed);
105
156
  }
106
157
  else {
107
- console.warn(`[sponsio] Could not parse: "${c}"`);
158
+ unparsed.push(c);
108
159
  }
109
160
  }
110
161
  else {
111
162
  this._contracts.push(c);
112
163
  }
113
164
  }
114
- // ── Sto pipeline is a Sponsio Cloud feature ──────────────────────
165
+ if (unparsed.length > 0) {
166
+ const listed = unparsed.map((c) => ` - ${c}`).join("\n");
167
+ if (this.mode === "enforce") {
168
+ throw new Error(`sponsio: ${unparsed.length} contract(s) could not be parsed, and ` +
169
+ `this guard is in enforce mode. THESE RULES ARE NOT ARMED. Fix ` +
170
+ `them, or write them as structured \`pattern:\` blocks in a ` +
171
+ `sponsio.yaml, which covers every pattern the library has.\n` +
172
+ listed);
173
+ }
174
+ console.warn(`[sponsio] ${unparsed.length} contract(s) could not be parsed and ` +
175
+ `are NOT armed (observe mode, so the run continues):\n${listed}`);
176
+ }
177
+ // ── Sto pipeline is not part of this build ──────────────────────
115
178
  // The managed LLM-judge catalog (`tone` / `relevance` / `llm_judge`
116
- // …) and the judge client live in the proprietary `sponsio_cloud`
117
- // package. The OSS engine logs-and-skips any yaml sto contracts
118
- // and any inline `judge:` option, never constructs an evaluator,
119
- // never contacts an LLM. The API surface (`guardAfter`,
120
- // `setContext`, `judge` ctor option) is preserved so a shared yaml
121
- // between Cloud and OSS-TS doesn't refuse to load.
179
+ // ...) and the judge client are not part of this build (the
180
+ // deterministic engine provides no implementation). The engine
181
+ // logs-and-skips any yaml sto contracts and any inline `judge:`
182
+ // option, never constructs an evaluator, never contacts an LLM.
183
+ // The API surface (`guardAfter`, `setContext`, `judge` ctor
184
+ // option) is preserved so a shared yaml between deterministic
185
+ // builds and external sto implementations doesn't refuse to load.
122
186
  for (const spec of yamlStoSpecs) {
123
187
  yamlSkipped.push({
124
188
  kind: "sto-contract",
125
- detail: `${spec.desc} (sto pipeline is a Sponsio Cloud feature; pip install sponsio[cloud])`,
189
+ detail: `${spec.desc} (sto pipeline is not supported in this build)`,
126
190
  });
127
191
  }
128
192
  if (options.judge) {
129
193
  yamlSkipped.push({
130
194
  kind: "sto-contract",
131
- detail: "`judge:` option ignored (sto pipeline is a Sponsio Cloud feature; pip install sponsio[cloud])",
195
+ detail: "`judge:` option ignored (sto pipeline is not supported in this build)",
132
196
  });
133
197
  }
134
- // Reference yamlJudge so unused-var lints don't fire — the field
135
- // is parsed by the loader for shared-yaml compatibility but the
136
- // OSS engine never builds a judge from it.
198
+ // Reference yamlJudge so unused-var lints don't fire: the field
199
+ // is parsed by the loader for shared-yaml compatibility but this
200
+ // build never constructs a judge from it.
137
201
  void yamlJudge;
138
202
  // ── Warn once about yaml features the TS runtime can't handle ───
139
203
  warnOnceAboutSkipped(yamlSkipped);
140
204
  this._contentAtoms = collectContentAtoms(this._contracts.map((c) => c.formula));
205
+ // A pattern that does not compile is a broken contract; refuse to
206
+ // arm it rather than let it never match at runtime.
207
+ validateContentPatterns(this._contentAtoms);
141
208
  // ── Session log ─────────────────────────────────────────────────
142
209
  const wantLog = options.sessionLog ?? true;
143
210
  this._logger = wantLog
144
211
  ? new SessionLogger(this.agentId, { baseDir: options.sessionLogBaseDir })
145
212
  : null;
146
213
  }
214
+ /**
215
+ * Predicates whose truth depends on the *contents* of a tool call's
216
+ * arguments. If a call arrives with no arguments, none of these keys is
217
+ * written and the evaluator reads a missing key as false, which makes
218
+ * ``argBlacklist`` (``!arg_field_has(...)``) report "satisfied" for a
219
+ * call nothing looked at. Mirrors Python's
220
+ * ``BaseGuard._ARG_SHAPED_PREDICATES``.
221
+ */
222
+ static ARG_SHAPED_PREDICATES = [
223
+ "arg_has",
224
+ "arg_field_has",
225
+ "arg_length_exceeds",
226
+ "arg_paths_within",
227
+ "arg_numeric",
228
+ "arg_value",
229
+ ];
230
+ /** Canonical names of the tools some contract reads the arguments of. */
231
+ _toolsNeedingArgs() {
232
+ const tools = new Set();
233
+ for (const pred of Sponsio.ARG_SHAPED_PREDICATES) {
234
+ for (const raw of this._contentAtoms[pred] ?? []) {
235
+ const tool = raw.split("|")[0];
236
+ if (tool)
237
+ tools.add(canonicalTool(tool));
238
+ }
239
+ }
240
+ return tools;
241
+ }
242
+ /** ``[tool, field]`` pairs some ``arg_numeric`` rule reads. */
243
+ _numericRuleTargets() {
244
+ const pairs = [];
245
+ for (const raw of this._contentAtoms["arg_numeric"] ?? []) {
246
+ const parts = raw.split("|");
247
+ if (parts.length >= 2)
248
+ pairs.push([parts[0], parts[1]]);
249
+ }
250
+ return pairs;
251
+ }
252
+ /**
253
+ * Refuse a call whose argument rules cannot be evaluated.
254
+ *
255
+ * Two cases, both of which used to read as "not violated":
256
+ *
257
+ * - The call arrived with no arguments (``{}``, ``undefined``, ``null``)
258
+ * but some rule reads this tool's arguments. An adapter that loses the
259
+ * arguments, a partial streamed call or a malformed payload all land
260
+ * here, and every argument check would pass vacuously.
261
+ * - A field an ``arg_numeric`` rule bounds is present but is not a
262
+ * number (``"five thousand"``). Grounding leaves ``arg_numeric`` unset
263
+ * and the evaluator reads the gap as 0, which passes every upper
264
+ * bound. An absent field is left alone: CLI-flag and positional rules
265
+ * on ``Bash`` legitimately see calls without their flag.
266
+ *
267
+ * ``SPONSIO_ALLOW_MISSING_ARGS=1`` restores the permissive behaviour.
268
+ * Mirrors Python ``BaseGuard._args_unevaluable`` and
269
+ * ``_unreadable_numeric_arg``; both block under rule id
270
+ * ``args:unevaluable``.
271
+ */
272
+ _unevaluableArgs(toolName, args) {
273
+ const env = typeof process !== "undefined" ? process.env : undefined;
274
+ if (env?.SPONSIO_ALLOW_MISSING_ARGS === "1")
275
+ return null;
276
+ const empty = args == null || (typeof args === "object" && Object.keys(args).length === 0);
277
+ if (empty) {
278
+ // Every spelling grounding binds the call under, so
279
+ // ``mcp__shell__Bash`` meets a rule written for ``Bash``.
280
+ const needed = this._toolsNeedingArgs();
281
+ if (!toolAliases(toolName).some((alias) => needed.has(alias)))
282
+ return null;
283
+ const message = `BLOCKED: ${this.agentId}.${toolName}. \`${toolName}\` was called ` +
284
+ `with no arguments, but a contract inspects its arguments. The ` +
285
+ `check cannot be evaluated, so the call is refused rather than ` +
286
+ `passed unchecked (set SPONSIO_ALLOW_MISSING_ARGS=1 to allow).`;
287
+ return {
288
+ desc: message,
289
+ message,
290
+ action: "blocked",
291
+ ruleId: "args:unevaluable",
292
+ agentMsg: `The action \`${toolName}\` was rejected because its arguments ` +
293
+ `were missing and a policy needs to read them. Retry with the ` +
294
+ `arguments included.`,
295
+ };
296
+ }
297
+ const aliases = toolAliases(toolName);
298
+ for (const [target, field] of this._numericRuleTargets()) {
299
+ if (!Object.prototype.hasOwnProperty.call(args, field))
300
+ continue;
301
+ if (!toolMatches(target, aliases))
302
+ continue;
303
+ const value = args[field];
304
+ if (value === null || value === undefined)
305
+ continue;
306
+ if (numericArg(value) !== undefined)
307
+ continue;
308
+ const shown = JSON.stringify(String(value).slice(0, 60));
309
+ const message = `BLOCKED: ${this.agentId}.${toolName}. \`${field}\`=${shown} is ` +
310
+ `not a number, but a contract bounds it numerically. The bound ` +
311
+ `cannot be checked, so the call is refused rather than passed ` +
312
+ `unchecked (set SPONSIO_ALLOW_MISSING_ARGS=1 to allow).`;
313
+ return {
314
+ desc: message,
315
+ message,
316
+ action: "blocked",
317
+ ruleId: "args:unevaluable",
318
+ agentMsg: `The action \`${toolName}\` was rejected because \`${field}\` ` +
319
+ `must be a plain number. Retry with a numeric value.`,
320
+ };
321
+ }
322
+ return null;
323
+ }
147
324
  /**
148
325
  * Check a tool call against contracts before execution.
149
326
  *
@@ -162,6 +339,20 @@ export class Sponsio {
162
339
  return this._contracts.map((c) => c.desc);
163
340
  }
164
341
  guardBefore(toolName, args = {}) {
342
+ const unevaluable = this._unevaluableArgs(toolName, args);
343
+ if (unevaluable) {
344
+ const violations = [unevaluable.message];
345
+ this._violations.push(...violations);
346
+ this._logViolations(toolName, violations, [unevaluable.desc], "blocked");
347
+ return {
348
+ ...deriveVerdict([unevaluable]),
349
+ allowed: false,
350
+ message: unevaluable.message,
351
+ violations,
352
+ detViolations: [unevaluable],
353
+ stoViolations: [],
354
+ };
355
+ }
165
356
  const event = { tool: toolName, args };
166
357
  const snapshot = this._snapshotState();
167
358
  const valuation = groundEvent(event, this._state, this._contentAtoms);
@@ -173,6 +364,7 @@ export class Sponsio {
173
364
  const violations = [];
174
365
  const violatedDescs = [];
175
366
  const detViolations = [];
367
+ let anyStopping = false;
176
368
  for (const contract of this._contracts) {
177
369
  const checkSpan = new ContractCheckSpan(contract.desc, "hard");
178
370
  collector.push(checkSpan);
@@ -198,37 +390,71 @@ export class Sponsio {
198
390
  const guaranteeSpan = new GuaranteeSpan(contract.desc, result);
199
391
  collector.add(guaranteeSpan, result ? "ok" : "violated");
200
392
  if (!result) {
201
- const verb = this.mode === "observe" ? "WOULD-BLOCK" : "BLOCKED";
393
+ // Per-contract `mode:` overrides the global mode for this one
394
+ // verdict — same routing as Python's RuntimeMonitor.
395
+ const effMode = contract.mode ?? this.mode;
396
+ // The contract's strategy decides which outcome this is, so a
397
+ // `redirect_to_safe` violation reports `redirected` with its
398
+ // substitute instead of collapsing into a plain block. Observe
399
+ // never stops, whatever the strategy says.
400
+ const action = effMode === "observe"
401
+ ? "observed"
402
+ : contract.strategy === "redirect"
403
+ ? "redirected"
404
+ : contract.strategy === "escalate"
405
+ ? "escalated"
406
+ : "blocked";
407
+ if (isStoppingAction(action))
408
+ anyStopping = true;
409
+ const verb = effMode === "observe" ? "WOULD-BLOCK" : "BLOCKED";
202
410
  const msg = `${verb}: ${this.agentId}.${toolName} — det constraint violated: ${contract.desc}`;
203
411
  violations.push(msg);
204
412
  violatedDescs.push(contract.desc);
413
+ const safe = action === "redirected" ? contract.safeName : undefined;
205
414
  detViolations.push({
206
415
  desc: contract.desc,
207
416
  message: msg,
417
+ action,
418
+ ...(safe !== undefined ? { redirectedTo: safe, alternatives: [safe] } : {}),
208
419
  ruleId: contract.patternName || contract.desc,
209
420
  agentMsg: `The action \`${toolName}\` was rejected by policy ` +
210
421
  `(${contract.patternName || contract.desc}): ${contract.desc}. ` +
211
- `Choose a different approach.`,
422
+ (safe !== undefined
423
+ ? `Call \`${safe}\` instead.`
424
+ : `Choose a different approach.`),
212
425
  });
213
426
  // Nest violation + enforcement spans under the failed
214
427
  // guarantee so the renderer can pick up the verdict word.
215
- const enforce = new EnforcementSpan("DetBlock", this.mode === "enforce" ? "blocked" : "observed");
428
+ const strategyName = contract.strategy === "redirect"
429
+ ? "RedirectToSafe"
430
+ : contract.strategy === "escalate"
431
+ ? "EscalateToHuman"
432
+ : "DetBlock";
433
+ const enforce = new EnforcementSpan(strategyName, action);
216
434
  guaranteeSpan.children.push(enforce);
217
435
  enforce.finish("violated");
218
436
  }
219
437
  collector.pop(result ? "ok" : "violated");
220
438
  }
221
439
  const hasViolations = violations.length > 0;
222
- const blocked = hasViolations && this.mode === "enforce";
223
- this._turnSpans.push(collector.finishRoot(blocked, this._contracts.length, violations.length));
224
- if (blocked) {
440
+ const stopping = anyStopping;
441
+ const turn = collector.finishRoot(stopping, this._contracts.length, violations.length);
442
+ this._turnSpans.push(turn);
443
+ this._runSpans.push({ span: turn, violations: [...detViolations] });
444
+ if (stopping) {
225
445
  this._trace.pop();
226
446
  this._state = snapshot;
227
447
  this._violations.push(...violations);
228
448
  this._logViolations(toolName, violations, violatedDescs, "blocked");
449
+ // `allowed` tracks `blocked` alone, not every stopping outcome:
450
+ // Python computes `not any(action == "blocked")`, so a redirect
451
+ // stays `allowed: true` because the agent flow continues down the
452
+ // safe path. `stopOriginal` is the field that says the original
453
+ // call must not run.
454
+ const derived = deriveVerdict(detViolations);
229
455
  return {
230
- blocked: true,
231
- allowed: false,
456
+ ...derived,
457
+ allowed: !derived.blocked,
232
458
  message: violations[0],
233
459
  violations,
234
460
  detViolations,
@@ -247,7 +473,7 @@ export class Sponsio {
247
473
  this._logAllow(toolName);
248
474
  }
249
475
  return {
250
- blocked: false,
476
+ ...deriveVerdict(detViolations),
251
477
  allowed: true,
252
478
  message: "",
253
479
  violations: [],
@@ -268,6 +494,63 @@ export class Sponsio {
268
494
  /** Drop accumulated turn spans (used between sessions in tests). */
269
495
  resetSession() {
270
496
  this._turnSpans = [];
497
+ this._runSpans = [];
498
+ this._sessionId = newSessionId();
499
+ this._startedAt = Date.now();
500
+ }
501
+ /**
502
+ * This run, in the shape the cloud stores.
503
+ *
504
+ * Exposed so a caller can see exactly what would be uploaded before
505
+ * uploading anything, which is the question a customer under a data
506
+ * agreement asks first.
507
+ */
508
+ viewModel() {
509
+ return buildViewModel({
510
+ agentId: this.agentId,
511
+ mode: this.mode,
512
+ sessionId: this._sessionId,
513
+ startedAt: this._startedAt,
514
+ contracts: this._contracts,
515
+ spans: this._runSpans,
516
+ privacyLevel: privacy.resolve(),
517
+ ...(this._rulebookStamp === undefined
518
+ ? {}
519
+ : { rulebookStamp: this._rulebookStamp }),
520
+ });
521
+ }
522
+ /**
523
+ * Upload this run.
524
+ *
525
+ * Best-effort by construction: it resolves false rather than throwing,
526
+ * because losing telemetry must never change what an agent does, and a
527
+ * rejected upload at the end of a run is not a reason to fail the run.
528
+ * Silent only about the network; a misconfiguration is worth saying
529
+ * once, since the symptom otherwise is a customer with no runs and no
530
+ * explanation.
531
+ */
532
+ async uploadSession(opts = {}) {
533
+ const client = opts.client ?? new CloudClient();
534
+ if (!client.configured)
535
+ return false;
536
+ // A per-customer key resolves to its own project and must not name
537
+ // another, so passing nothing is both correct and safest here.
538
+ const project = opts.project === undefined ? readProject() : opts.project;
539
+ try {
540
+ await client.ingestSession(project ?? null, {
541
+ key: this._sessionId,
542
+ live: false,
543
+ vm: this.viewModel(),
544
+ });
545
+ return true;
546
+ }
547
+ catch (err) {
548
+ if ((process.env.SPONSIO_QUIET ?? "") !== "1") {
549
+ const msg = err instanceof Error ? err.message : String(err);
550
+ process.stderr.write(`sponsio: run not uploaded (${msg})\n`);
551
+ }
552
+ return false;
553
+ }
271
554
  }
272
555
  /**
273
556
  * Render the end-of-session view to ``out`` (defaults to stderr) —
@@ -290,6 +573,7 @@ export class Sponsio {
290
573
  callCounts: { ...this._state.callCounts },
291
574
  callWithCounts: { ...this._state.callWithCounts },
292
575
  lastTool: this._state.lastTool,
576
+ lastToolAliases: [...this._state.lastToolAliases],
293
577
  consecutiveCounts: { ...this._state.consecutiveCounts },
294
578
  tokenCounts: { ...this._state.tokenCounts },
295
579
  delegationDepth: this._state.delegationDepth,
@@ -345,32 +629,73 @@ export class Sponsio {
345
629
  * violations the same way they branch on ``guardBefore``. In
346
630
  * enforce mode, ``blocked: true`` rolls back the synthetic event.
347
631
  */
632
+ /**
633
+ * Record a claim-verification verdict in the trace and check the
634
+ * contracts that reference it.
635
+ *
636
+ * Grounds `claim_emitted(pred)`, `evidence_verdict(pred, V)` and
637
+ * `evidence_action(pred, a)` at one timestep, so contracts built from
638
+ * `claimRequiresEvidence` / `underdeterminedMustClarify` can decide.
639
+ * No verdict is computed here: the atoms restate what
640
+ * `/v1/evidence/verify` answered.
641
+ *
642
+ * Parity with feeding `EvidenceResult.to_event()` through Python's
643
+ * trace path.
644
+ */
645
+ observeEvidence(predicate, verdict, action) {
646
+ return this._checkSynthetic({
647
+ tool: "",
648
+ event_type: "evidence",
649
+ key: predicate,
650
+ args: { verdict, action },
651
+ }, `<evidence:${predicate}>`);
652
+ }
348
653
  observeResponse(content, opts = {}) {
349
654
  const args = {};
350
655
  if (opts.segment)
351
656
  args.segment = opts.segment;
352
- const event = {
353
- tool: "",
354
- event_type: "llm_response",
355
- content,
356
- args,
357
- };
657
+ return this._checkSynthetic({ tool: "", event_type: "llm_response", content, args }, "<llm_response>");
658
+ }
659
+ /**
660
+ * Check one synthetic (non-tool-call) event against every contract.
661
+ *
662
+ * The output lane and the evidence lane both push an event that is not
663
+ * a tool call and then judge the trace. Sharing the body keeps their
664
+ * verdicts identical: a second copy is how one lane ends up with a
665
+ * rollback the other lacks.
666
+ *
667
+ * `label` names the subject in violation messages and session logs.
668
+ */
669
+ _checkSynthetic(event, label) {
358
670
  const snapshot = this._snapshotState();
359
671
  const valuation = groundEvent(event, this._state, this._contentAtoms);
360
672
  this._trace.push(valuation);
361
673
  const violations = [];
362
674
  const violatedDescs = [];
363
675
  const detViolations = [];
676
+ let anyStopping = false;
364
677
  for (const contract of this._contracts) {
365
678
  const result = evaluate(contract.formula, this._trace);
366
679
  if (!result) {
367
- const verb = this.mode === "observe" ? "WOULD-BLOCK" : "BLOCKED";
368
- const msg = `${verb}: ${this.agentId}.<llm_response> — det constraint violated: ${contract.desc}`;
680
+ const effMode = contract.mode ?? this.mode;
681
+ // No substitution on the output lane: there is no call to swap,
682
+ // so a redirect strategy still stops here. `escalate` keeps its
683
+ // own action, which does not stop.
684
+ const action = effMode === "observe"
685
+ ? "observed"
686
+ : contract.strategy === "escalate"
687
+ ? "escalated"
688
+ : "blocked";
689
+ if (isStoppingAction(action))
690
+ anyStopping = true;
691
+ const verb = effMode === "observe" ? "WOULD-BLOCK" : "BLOCKED";
692
+ const msg = `${verb}: ${this.agentId}.${label} — det constraint violated: ${contract.desc}`;
369
693
  violations.push(msg);
370
694
  violatedDescs.push(contract.desc);
371
695
  detViolations.push({
372
696
  desc: contract.desc,
373
697
  message: msg,
698
+ action,
374
699
  ruleId: contract.patternName || contract.desc,
375
700
  agentMsg: `LLM response was rejected by policy ` +
376
701
  `(${contract.patternName || contract.desc}): ${contract.desc}.`,
@@ -378,15 +703,36 @@ export class Sponsio {
378
703
  }
379
704
  }
380
705
  const hasViolations = violations.length > 0;
381
- const blocked = hasViolations && this.mode === "enforce";
382
- if (blocked) {
706
+ const stopping = anyStopping;
707
+ // The output lane builds no span tree, so without this a rule that
708
+ // fires on what the model said was enforced locally and never
709
+ // uploaded: at `full` a TypeScript run showed half the run a Python
710
+ // one showed. A bare turn is enough for the cloud, which reads the
711
+ // step's type, tool and verdict and not the tree under it.
712
+ const outTurn = new AgentTurnSpan(this.agentId, label);
713
+ outTurn.totalContractsChecked = this._contracts.length;
714
+ outTurn.detViolations = violations.length;
715
+ outTurn.blocked = stopping;
716
+ outTurn.finish(hasViolations ? "violated" : "ok");
717
+ this._runSpans.push({
718
+ span: outTurn,
719
+ violations: [...detViolations],
720
+ type: "output",
721
+ });
722
+ if (stopping) {
383
723
  this._trace.pop();
384
724
  this._state = snapshot;
385
725
  this._violations.push(...violations);
386
- this._logViolations("<llm_response>", violations, violatedDescs, "blocked");
726
+ this._logViolations(label, violations, violatedDescs, "blocked");
727
+ // `allowed` tracks `blocked` alone, not every stopping outcome:
728
+ // Python computes `not any(action == "blocked")`, so a redirect
729
+ // stays `allowed: true` because the agent flow continues down the
730
+ // safe path. `stopOriginal` is the field that says the original
731
+ // call must not run.
732
+ const derived = deriveVerdict(detViolations);
387
733
  return {
388
- blocked: true,
389
- allowed: false,
734
+ ...derived,
735
+ allowed: !derived.blocked,
390
736
  message: violations[0],
391
737
  violations,
392
738
  detViolations,
@@ -395,10 +741,10 @@ export class Sponsio {
395
741
  }
396
742
  if (hasViolations) {
397
743
  this._violations.push(...violations);
398
- this._logViolations("<llm_response>", violations, violatedDescs, "observed");
744
+ this._logViolations(label, violations, violatedDescs, "observed");
399
745
  }
400
746
  return {
401
- blocked: false,
747
+ ...deriveVerdict(detViolations),
402
748
  allowed: true,
403
749
  message: "",
404
750
  violations: [],
@@ -436,23 +782,24 @@ export class Sponsio {
436
782
  /**
437
783
  * Record tool output after execution.
438
784
  *
439
- * The OSS engine ships **no sto pipeline** — Cloud's stochastic
440
- * (LLM-judged) atoms are what populate ``stoViolations``. With no
441
- * Cloud installed and no sto contracts loaded (sto entries in yaml
442
- * are rejected at config load), ``stoViolations`` is always
443
- * ``[]`` and ``allowed: true``.
785
+ * This build ships **no sto pipeline** (the engine is deterministic-only).
786
+ * Stochastic (LLM-judged) atoms are the extension point that would
787
+ * populate ``stoViolations``; with no external implementation and no
788
+ * sto contracts loaded (sto entries in yaml are rejected at config
789
+ * load), ``stoViolations`` is always ``[]`` and ``allowed: true``.
444
790
  *
445
- * The method stays ``async`` for API parity with Cloud's override —
446
- * a Cloud-side subclass / wrapper can run the judge pipeline and
447
- * surface real ``stoViolations`` without changing the call site.
448
- * In **enforce mode** Cloud subclasses flip ``blocked: true`` on
449
- * the first sto violation; the tool output has already executed,
450
- * so the caller is responsible for routing the result (retry with
791
+ * The method stays ``async`` for API parity: an external subclass
792
+ * or wrapper can run the judge pipeline and surface real
793
+ * ``stoViolations`` without changing the call site. In **enforce
794
+ * mode** such subclasses can flip ``blocked: true`` on the first
795
+ * sto violation; the tool output has already executed, so the
796
+ * caller is responsible for routing the result (retry with
451
797
  * feedback, redirect to safe, etc.).
452
798
  */
453
799
  async guardAfter(_toolName, _output = "") {
454
- // OSS is det-only — sto pipeline lives in Sponsio Cloud. Always
455
- // return clean-pass; Cloud subclasses override.
800
+ // This build is deterministic-only, the sto pipeline is not
801
+ // implemented. Always return clean-pass; external subclasses can
802
+ // override.
456
803
  return emptyAllow();
457
804
  }
458
805
  /**
@@ -470,13 +817,13 @@ export class Sponsio {
470
817
  /**
471
818
  * Stash per-turn context for the sto pipeline.
472
819
  *
473
- * Sponsio Cloud only — atoms that need grounding (``relevance`` →
474
- * ``query``, ``hallucination_free`` → ``source``, ``scope_respect``
475
- * → ``scope`` override, ``metric_integrity`` → ``history``) read
476
- * from this snapshot on the next ``guardAfter`` call. The OSS
477
- * engine accepts the call as a no-op so shared agent code can
478
- * stay framework-neutral; without Cloud, the snapshot is never
479
- * consumed.
820
+ * Not supported in this build (the engine is deterministic-only).
821
+ * Atoms that need grounding (``relevance`` -> ``query``,
822
+ * ``hallucination_free`` -> ``source``, ``scope_respect`` ->
823
+ * ``scope`` override, ``metric_integrity`` -> ``history``) would
824
+ * read from this snapshot on the next ``guardAfter`` call. This
825
+ * build accepts the call as a no-op so shared agent code can stay
826
+ * framework-neutral; the snapshot is never consumed.
480
827
  *
481
828
  * Merges with any previously-set context; pass ``{ query: undefined }``
482
829
  * to explicitly clear a field. ``reset()`` clears the whole snapshot.
@@ -534,12 +881,13 @@ function resolveMode(ctorMode, yamlMode) {
534
881
  }
535
882
  let _skippedWarned = false;
536
883
  // Note: ``resolveJudge`` / ``buildStoContract`` / ``isJudgeClient``
537
- // helpers were removed when the sto pipeline became Sponsio Cloud-only.
538
- // The OSS engine never constructs a judge or evaluator; yaml sto
539
- // specs flow through the ``yamlSkipped`` warning path in the ctor.
884
+ // helpers were removed when the sto pipeline became an external
885
+ // extension point. This build never constructs a judge or evaluator;
886
+ // yaml sto specs flow through the ``yamlSkipped`` warning path in
887
+ // the ctor.
540
888
  function emptyAllow() {
541
889
  return {
542
- blocked: false,
890
+ ...deriveVerdict([]),
543
891
  allowed: true,
544
892
  message: "",
545
893
  violations: [],
@@ -569,7 +917,7 @@ function warnOnceAboutSkipped(skipped) {
569
917
  bits.push(`${unknown} unrecognised contract entr${unknown === 1 ? "y" : "ies"}`);
570
918
  }
571
919
  const stoNote = sto > 0
572
- ? " Sto (LLM-judge) contracts are part of Sponsio Cloud — install `sponsio[cloud]` for the managed pipeline."
920
+ ? " Sto (LLM-judge) contracts are not supported in this build (the engine is deterministic-only)."
573
921
  : "";
574
922
  console.warn("[sponsio] skipped unsupported yaml items: " +
575
923
  bits.join("; ") +