@sponsio/sdk 0.2.0-alpha.4 → 0.2.0-alpha.9
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/contracts/capability/destructive.yaml +213 -0
- package/contracts/capability/factual-claims.yaml +79 -0
- package/contracts/capability/host-config-integrity.yaml +42 -9
- package/contracts/capability/self-modify.yaml +32 -1
- package/contracts/capability/shell.yaml +19 -10
- package/contracts/incident/claude-code-secret-bypass.yaml +46 -14
- package/contracts/incident/cursor-railway-wipe.yaml +60 -4
- package/contracts/incident/openclaw.yaml +9 -63
- package/dist/cli/cli.js +32 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/packs.d.ts.map +1 -1
- package/dist/cli/packs.js +13 -7
- package/dist/cli/packs.js.map +1 -1
- package/dist/cloud/client.d.ts +76 -0
- package/dist/cloud/client.d.ts.map +1 -0
- package/dist/cloud/client.js +198 -0
- package/dist/cloud/client.js.map +1 -0
- package/dist/cloud/evidence.d.ts +108 -0
- package/dist/cloud/evidence.d.ts.map +1 -0
- package/dist/cloud/evidence.js +184 -0
- package/dist/cloud/evidence.js.map +1 -0
- package/dist/cloud/privacy.d.ts +67 -0
- package/dist/cloud/privacy.d.ts.map +1 -0
- package/dist/cloud/privacy.js +170 -0
- package/dist/cloud/privacy.js.map +1 -0
- package/dist/cloud/view-model.d.ts +49 -0
- package/dist/cloud/view-model.d.ts.map +1 -0
- package/dist/cloud/view-model.js +172 -0
- package/dist/cloud/view-model.js.map +1 -0
- package/dist/core/config-loader.js +16 -0
- package/dist/core/config-loader.js.map +1 -1
- package/dist/core/evaluator.d.ts +2 -0
- package/dist/core/evaluator.d.ts.map +1 -1
- package/dist/core/evaluator.js +90 -4
- package/dist/core/evaluator.js.map +1 -1
- package/dist/core/formula.d.ts.map +1 -1
- package/dist/core/formula.js +12 -0
- package/dist/core/formula.js.map +1 -1
- package/dist/core/grounding.d.ts +43 -7
- package/dist/core/grounding.d.ts.map +1 -1
- package/dist/core/grounding.js +192 -26
- package/dist/core/grounding.js.map +1 -1
- package/dist/core/nl-parser.d.ts +1 -1
- package/dist/core/nl-parser.d.ts.map +1 -1
- package/dist/core/nl-parser.js +616 -40
- package/dist/core/nl-parser.js.map +1 -1
- package/dist/core/pattern-factory.d.ts.map +1 -1
- package/dist/core/pattern-factory.js +7 -1
- package/dist/core/pattern-factory.js.map +1 -1
- package/dist/core/patterns.d.ts +69 -0
- package/dist/core/patterns.d.ts.map +1 -1
- package/dist/core/patterns.js +133 -10
- package/dist/core/patterns.js.map +1 -1
- package/dist/core/tool-names.d.ts +59 -0
- package/dist/core/tool-names.d.ts.map +1 -0
- package/dist/core/tool-names.js +128 -0
- package/dist/core/tool-names.js.map +1 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +367 -32
- package/dist/index.js.map +1 -1
- package/dist/integrations/claude-agent.d.ts +5 -0
- package/dist/integrations/claude-agent.d.ts.map +1 -1
- package/dist/integrations/claude-agent.js +42 -13
- package/dist/integrations/claude-agent.js.map +1 -1
- package/dist/integrations/google-adk.d.ts +5 -3
- package/dist/integrations/google-adk.d.ts.map +1 -1
- package/dist/integrations/google-adk.js +9 -5
- package/dist/integrations/google-adk.js.map +1 -1
- package/dist/integrations/langchain.d.ts +7 -0
- package/dist/integrations/langchain.d.ts.map +1 -1
- package/dist/integrations/langchain.js +21 -3
- package/dist/integrations/langchain.js.map +1 -1
- package/dist/integrations/openai-agents.d.ts +22 -10
- package/dist/integrations/openai-agents.d.ts.map +1 -1
- package/dist/integrations/openai-agents.js +51 -12
- package/dist/integrations/openai-agents.js.map +1 -1
- package/dist/integrations/openai.d.ts +11 -0
- package/dist/integrations/openai.d.ts.map +1 -1
- package/dist/integrations/openai.js +130 -48
- package/dist/integrations/openai.js.map +1 -1
- package/dist/integrations/vercel-ai.d.ts +20 -9
- package/dist/integrations/vercel-ai.d.ts.map +1 -1
- package/dist/integrations/vercel-ai.js +131 -50
- package/dist/integrations/vercel-ai.js.map +1 -1
- package/package.json +12 -8
- package/prompts/onboard.md +17 -4
- package/contracts/core/llm_safety.yaml +0 -138
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,6 +70,26 @@ 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;
|
|
@@ -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,13 +155,25 @@ export class Sponsio {
|
|
|
104
155
|
this._contracts.push(parsed);
|
|
105
156
|
}
|
|
106
157
|
else {
|
|
107
|
-
|
|
158
|
+
unparsed.push(c);
|
|
108
159
|
}
|
|
109
160
|
}
|
|
110
161
|
else {
|
|
111
162
|
this._contracts.push(c);
|
|
112
163
|
}
|
|
113
164
|
}
|
|
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
|
+
}
|
|
114
177
|
// ── Sto pipeline is not part of this build ──────────────────────
|
|
115
178
|
// The managed LLM-judge catalog (`tone` / `relevance` / `llm_judge`
|
|
116
179
|
// ...) and the judge client are not part of this build (the
|
|
@@ -139,12 +202,125 @@ export class Sponsio {
|
|
|
139
202
|
// ── Warn once about yaml features the TS runtime can't handle ───
|
|
140
203
|
warnOnceAboutSkipped(yamlSkipped);
|
|
141
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);
|
|
142
208
|
// ── Session log ─────────────────────────────────────────────────
|
|
143
209
|
const wantLog = options.sessionLog ?? true;
|
|
144
210
|
this._logger = wantLog
|
|
145
211
|
? new SessionLogger(this.agentId, { baseDir: options.sessionLogBaseDir })
|
|
146
212
|
: null;
|
|
147
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
|
+
}
|
|
148
324
|
/**
|
|
149
325
|
* Check a tool call against contracts before execution.
|
|
150
326
|
*
|
|
@@ -163,6 +339,20 @@ export class Sponsio {
|
|
|
163
339
|
return this._contracts.map((c) => c.desc);
|
|
164
340
|
}
|
|
165
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
|
+
}
|
|
166
356
|
const event = { tool: toolName, args };
|
|
167
357
|
const snapshot = this._snapshotState();
|
|
168
358
|
const valuation = groundEvent(event, this._state, this._contentAtoms);
|
|
@@ -174,7 +364,7 @@ export class Sponsio {
|
|
|
174
364
|
const violations = [];
|
|
175
365
|
const violatedDescs = [];
|
|
176
366
|
const detViolations = [];
|
|
177
|
-
let
|
|
367
|
+
let anyStopping = false;
|
|
178
368
|
for (const contract of this._contracts) {
|
|
179
369
|
const checkSpan = new ContractCheckSpan(contract.desc, "hard");
|
|
180
370
|
collector.push(checkSpan);
|
|
@@ -203,39 +393,68 @@ export class Sponsio {
|
|
|
203
393
|
// Per-contract `mode:` overrides the global mode for this one
|
|
204
394
|
// verdict — same routing as Python's RuntimeMonitor.
|
|
205
395
|
const effMode = contract.mode ?? this.mode;
|
|
206
|
-
|
|
207
|
-
|
|
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;
|
|
208
409
|
const verb = effMode === "observe" ? "WOULD-BLOCK" : "BLOCKED";
|
|
209
410
|
const msg = `${verb}: ${this.agentId}.${toolName} — det constraint violated: ${contract.desc}`;
|
|
210
411
|
violations.push(msg);
|
|
211
412
|
violatedDescs.push(contract.desc);
|
|
413
|
+
const safe = action === "redirected" ? contract.safeName : undefined;
|
|
212
414
|
detViolations.push({
|
|
213
415
|
desc: contract.desc,
|
|
214
416
|
message: msg,
|
|
417
|
+
action,
|
|
418
|
+
...(safe !== undefined ? { redirectedTo: safe, alternatives: [safe] } : {}),
|
|
215
419
|
ruleId: contract.patternName || contract.desc,
|
|
216
420
|
agentMsg: `The action \`${toolName}\` was rejected by policy ` +
|
|
217
421
|
`(${contract.patternName || contract.desc}): ${contract.desc}. ` +
|
|
218
|
-
|
|
422
|
+
(safe !== undefined
|
|
423
|
+
? `Call \`${safe}\` instead.`
|
|
424
|
+
: `Choose a different approach.`),
|
|
219
425
|
});
|
|
220
426
|
// Nest violation + enforcement spans under the failed
|
|
221
427
|
// guarantee so the renderer can pick up the verdict word.
|
|
222
|
-
const
|
|
428
|
+
const strategyName = contract.strategy === "redirect"
|
|
429
|
+
? "RedirectToSafe"
|
|
430
|
+
: contract.strategy === "escalate"
|
|
431
|
+
? "EscalateToHuman"
|
|
432
|
+
: "DetBlock";
|
|
433
|
+
const enforce = new EnforcementSpan(strategyName, action);
|
|
223
434
|
guaranteeSpan.children.push(enforce);
|
|
224
435
|
enforce.finish("violated");
|
|
225
436
|
}
|
|
226
437
|
collector.pop(result ? "ok" : "violated");
|
|
227
438
|
}
|
|
228
439
|
const hasViolations = violations.length > 0;
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
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) {
|
|
232
445
|
this._trace.pop();
|
|
233
446
|
this._state = snapshot;
|
|
234
447
|
this._violations.push(...violations);
|
|
235
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);
|
|
236
455
|
return {
|
|
237
|
-
|
|
238
|
-
allowed:
|
|
456
|
+
...derived,
|
|
457
|
+
allowed: !derived.blocked,
|
|
239
458
|
message: violations[0],
|
|
240
459
|
violations,
|
|
241
460
|
detViolations,
|
|
@@ -254,7 +473,7 @@ export class Sponsio {
|
|
|
254
473
|
this._logAllow(toolName);
|
|
255
474
|
}
|
|
256
475
|
return {
|
|
257
|
-
|
|
476
|
+
...deriveVerdict(detViolations),
|
|
258
477
|
allowed: true,
|
|
259
478
|
message: "",
|
|
260
479
|
violations: [],
|
|
@@ -275,6 +494,63 @@ export class Sponsio {
|
|
|
275
494
|
/** Drop accumulated turn spans (used between sessions in tests). */
|
|
276
495
|
resetSession() {
|
|
277
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
|
+
}
|
|
278
554
|
}
|
|
279
555
|
/**
|
|
280
556
|
* Render the end-of-session view to ``out`` (defaults to stderr) —
|
|
@@ -297,6 +573,7 @@ export class Sponsio {
|
|
|
297
573
|
callCounts: { ...this._state.callCounts },
|
|
298
574
|
callWithCounts: { ...this._state.callWithCounts },
|
|
299
575
|
lastTool: this._state.lastTool,
|
|
576
|
+
lastToolAliases: [...this._state.lastToolAliases],
|
|
300
577
|
consecutiveCounts: { ...this._state.consecutiveCounts },
|
|
301
578
|
tokenCounts: { ...this._state.tokenCounts },
|
|
302
579
|
delegationDepth: this._state.delegationDepth,
|
|
@@ -352,36 +629,73 @@ export class Sponsio {
|
|
|
352
629
|
* violations the same way they branch on ``guardBefore``. In
|
|
353
630
|
* enforce mode, ``blocked: true`` rolls back the synthetic event.
|
|
354
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
|
+
}
|
|
355
653
|
observeResponse(content, opts = {}) {
|
|
356
654
|
const args = {};
|
|
357
655
|
if (opts.segment)
|
|
358
656
|
args.segment = opts.segment;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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) {
|
|
365
670
|
const snapshot = this._snapshotState();
|
|
366
671
|
const valuation = groundEvent(event, this._state, this._contentAtoms);
|
|
367
672
|
this._trace.push(valuation);
|
|
368
673
|
const violations = [];
|
|
369
674
|
const violatedDescs = [];
|
|
370
675
|
const detViolations = [];
|
|
371
|
-
let
|
|
676
|
+
let anyStopping = false;
|
|
372
677
|
for (const contract of this._contracts) {
|
|
373
678
|
const result = evaluate(contract.formula, this._trace);
|
|
374
679
|
if (!result) {
|
|
375
680
|
const effMode = contract.mode ?? this.mode;
|
|
376
|
-
|
|
377
|
-
|
|
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;
|
|
378
691
|
const verb = effMode === "observe" ? "WOULD-BLOCK" : "BLOCKED";
|
|
379
|
-
const msg = `${verb}: ${this.agentId}
|
|
692
|
+
const msg = `${verb}: ${this.agentId}.${label} — det constraint violated: ${contract.desc}`;
|
|
380
693
|
violations.push(msg);
|
|
381
694
|
violatedDescs.push(contract.desc);
|
|
382
695
|
detViolations.push({
|
|
383
696
|
desc: contract.desc,
|
|
384
697
|
message: msg,
|
|
698
|
+
action,
|
|
385
699
|
ruleId: contract.patternName || contract.desc,
|
|
386
700
|
agentMsg: `LLM response was rejected by policy ` +
|
|
387
701
|
`(${contract.patternName || contract.desc}): ${contract.desc}.`,
|
|
@@ -389,15 +703,36 @@ export class Sponsio {
|
|
|
389
703
|
}
|
|
390
704
|
}
|
|
391
705
|
const hasViolations = violations.length > 0;
|
|
392
|
-
const
|
|
393
|
-
|
|
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) {
|
|
394
723
|
this._trace.pop();
|
|
395
724
|
this._state = snapshot;
|
|
396
725
|
this._violations.push(...violations);
|
|
397
|
-
this._logViolations(
|
|
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);
|
|
398
733
|
return {
|
|
399
|
-
|
|
400
|
-
allowed:
|
|
734
|
+
...derived,
|
|
735
|
+
allowed: !derived.blocked,
|
|
401
736
|
message: violations[0],
|
|
402
737
|
violations,
|
|
403
738
|
detViolations,
|
|
@@ -406,10 +741,10 @@ export class Sponsio {
|
|
|
406
741
|
}
|
|
407
742
|
if (hasViolations) {
|
|
408
743
|
this._violations.push(...violations);
|
|
409
|
-
this._logViolations(
|
|
744
|
+
this._logViolations(label, violations, violatedDescs, "observed");
|
|
410
745
|
}
|
|
411
746
|
return {
|
|
412
|
-
|
|
747
|
+
...deriveVerdict(detViolations),
|
|
413
748
|
allowed: true,
|
|
414
749
|
message: "",
|
|
415
750
|
violations: [],
|
|
@@ -552,7 +887,7 @@ let _skippedWarned = false;
|
|
|
552
887
|
// the ctor.
|
|
553
888
|
function emptyAllow() {
|
|
554
889
|
return {
|
|
555
|
-
|
|
890
|
+
...deriveVerdict([]),
|
|
556
891
|
allowed: true,
|
|
557
892
|
message: "",
|
|
558
893
|
violations: [],
|