@themoltnet/pi-runtime 0.16.0 → 0.18.0
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/dist/index.d.ts +34 -1
- package/dist/index.js +276 -18
- package/dist/pi-config.d.ts +26 -2
- package/dist/pi-config.js +22 -3
- package/dist/testing.d.ts +87 -0
- package/dist/testing.js +884 -0
- package/package.json +10 -6
package/dist/index.d.ts
CHANGED
|
@@ -461,6 +461,11 @@ export declare function decideForEvent(event: ToolCallEvent, policy: SessionTool
|
|
|
461
461
|
* 4. **Output redirection** — a `bash` command that redirects output (`>`,
|
|
462
462
|
* `2>`, `>>`, `&>`, …). No shell-command rule authorizes it; file writes go
|
|
463
463
|
* through structured tools.
|
|
464
|
+
* 5. **Execution-redirecting environment prefixes** — a `VAR=value` prefix that
|
|
465
|
+
* changes which binary argv names or injects code into it (`PATH`, `LD_*`,
|
|
466
|
+
* `BASH_ENV`, …; see {@link UNSAFE_ENV_NAMES}). Such a prefix is not argv, so
|
|
467
|
+
* the matched rule would describe a different program than the one that runs.
|
|
468
|
+
* Benign assignments are unaffected.
|
|
464
469
|
*
|
|
465
470
|
* KNOWN LIMITATION (follow-up): the `escapable` risk tier (GTFOBins binaries
|
|
466
471
|
* like `find`, `tar`, `awk` that document shell-spawn / file-write techniques)
|
|
@@ -1529,13 +1534,41 @@ export declare interface ToolPolicyDecisionContext {
|
|
|
1529
1534
|
* - `{ allow: false, reason }` — block it (enforce mode).
|
|
1530
1535
|
* - `{ audit, ... }` — would-block, but proceed and record it (watch mode).
|
|
1531
1536
|
*/
|
|
1532
|
-
export declare type ToolPolicyDecisionReason = 'policy_off' | 'executor_protocol_tool' | 'policy_allowed' | 'shell_command_prefix_allowed' | 'shell_command_unresolvable' | 'arbitrary_code_interpreter' | 'shell_output_redirection_not_permitted' | 'tool_not_permitted';
|
|
1537
|
+
export declare type ToolPolicyDecisionReason = 'policy_off' | 'executor_protocol_tool' | 'policy_allowed' | 'shell_command_prefix_allowed' | 'shell_command_unresolvable' | 'arbitrary_code_interpreter' | 'shell_output_redirection_not_permitted' | 'unsafe_environment_assignment' | 'tool_not_permitted';
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* A refusal, shaped for the task record. Carries no argv literals: the
|
|
1541
|
+
* executables and the fingerprint identify an invocation without reproducing
|
|
1542
|
+
* its arguments, which is what lets this reach a persisted, readable record
|
|
1543
|
+
* with no redaction rule.
|
|
1544
|
+
*/
|
|
1545
|
+
declare interface ToolPolicyDecisionRecord {
|
|
1546
|
+
/** `blocked` in enforce, `would_block` in watch. */
|
|
1547
|
+
decision: 'blocked' | 'would_block';
|
|
1548
|
+
tool_name: string;
|
|
1549
|
+
tool_call_id?: string;
|
|
1550
|
+
reason_code: ToolPolicyDecisionReason;
|
|
1551
|
+
enforcement: ToolEnforcement;
|
|
1552
|
+
/** Executables the policy did not authorize. */
|
|
1553
|
+
unauthorized_executables?: string[];
|
|
1554
|
+
/** Literal-free identification of each refused shell invocation. */
|
|
1555
|
+
shell_fingerprints?: MissingShellCommand[];
|
|
1556
|
+
degraded: boolean;
|
|
1557
|
+
policy_snapshot_hash?: string;
|
|
1558
|
+
runtime_profile_revision?: number;
|
|
1559
|
+
}
|
|
1533
1560
|
|
|
1534
1561
|
export declare interface ToolPolicyExtensionDeps {
|
|
1535
1562
|
policy: SessionToolPolicy;
|
|
1536
1563
|
analyzer: ShellCommandAnalyzer;
|
|
1537
1564
|
logger: ToolPolicyLogger;
|
|
1538
1565
|
context?: ToolPolicyDecisionContext;
|
|
1566
|
+
/**
|
|
1567
|
+
* Called for every refusal so it reaches the task record, not only the
|
|
1568
|
+
* daemon log. Pi's `tool_call` handler is synchronous, so this must not
|
|
1569
|
+
* block: implementations fire and forget.
|
|
1570
|
+
*/
|
|
1571
|
+
onDecision?: (decision: ToolPolicyDecisionRecord) => void;
|
|
1539
1572
|
}
|
|
1540
1573
|
|
|
1541
1574
|
/**
|
package/dist/index.js
CHANGED
|
@@ -2285,7 +2285,7 @@ function createMoltNetTools(config) {
|
|
|
2285
2285
|
}
|
|
2286
2286
|
//#endregion
|
|
2287
2287
|
//#region src/otel/index.ts
|
|
2288
|
-
var TRACER_NAME = "@themoltnet/pi-extension/otel";
|
|
2288
|
+
var TRACER_NAME$1 = "@themoltnet/pi-extension/otel";
|
|
2289
2289
|
function stripReservedAttrs(attrs) {
|
|
2290
2290
|
const out = {};
|
|
2291
2291
|
for (const [k, v] of Object.entries(attrs)) {
|
|
@@ -2296,7 +2296,7 @@ function stripReservedAttrs(attrs) {
|
|
|
2296
2296
|
}
|
|
2297
2297
|
function createPiOtelExtension(options = {}) {
|
|
2298
2298
|
return function piOtelExtension(pi) {
|
|
2299
|
-
const tracer = trace.getTracer(TRACER_NAME);
|
|
2299
|
+
const tracer = trace.getTracer(TRACER_NAME$1);
|
|
2300
2300
|
const extraAttrs = stripReservedAttrs(options.spanAttributes ?? {});
|
|
2301
2301
|
let sessionSpan;
|
|
2302
2302
|
let sessionCtx = context.active();
|
|
@@ -3946,8 +3946,123 @@ function createGondolinBashOps(vm, localCwd, guestWorkspace, config) {
|
|
|
3946
3946
|
} };
|
|
3947
3947
|
}
|
|
3948
3948
|
//#endregion
|
|
3949
|
+
//#region src/tool-policy/decision-sink.ts
|
|
3950
|
+
function createToolPolicyDecisionSink(emit) {
|
|
3951
|
+
const pending = [];
|
|
3952
|
+
return {
|
|
3953
|
+
record(record) {
|
|
3954
|
+
try {
|
|
3955
|
+
pending.push(Promise.resolve(emit(record)).catch(() => void 0));
|
|
3956
|
+
} catch {}
|
|
3957
|
+
},
|
|
3958
|
+
async drain() {
|
|
3959
|
+
while (pending.length > 0) {
|
|
3960
|
+
const batch = pending.splice(0, pending.length);
|
|
3961
|
+
await Promise.all(batch);
|
|
3962
|
+
}
|
|
3963
|
+
},
|
|
3964
|
+
get size() {
|
|
3965
|
+
return pending.length;
|
|
3966
|
+
}
|
|
3967
|
+
};
|
|
3968
|
+
}
|
|
3969
|
+
//#endregion
|
|
3970
|
+
//#region src/redact.ts
|
|
3971
|
+
var REDACTED = "[redacted]";
|
|
3972
|
+
/**
|
|
3973
|
+
* Replace values matching well-known credential shapes.
|
|
3974
|
+
*
|
|
3975
|
+
* Shape matching only: it catches the common accident (a pasted token, a
|
|
3976
|
+
* bearer header, an `env` dump) and nothing else. It is not a guarantee that a
|
|
3977
|
+
* string is free of secrets, and must never be described as one. Content-aware
|
|
3978
|
+
* processing is #1294's job; this is the cheap layer that composes with it.
|
|
3979
|
+
*/
|
|
3980
|
+
function redactKnownSecretShapes(value) {
|
|
3981
|
+
return value.replace(/((?:bearer|basic)\s+)[a-z0-9._~+/=-]{16,}/gi, `$1${REDACTED}`).replace(/\bgh[pousr]_[a-z0-9_]{20,}\b/gi, REDACTED).replace(/\bsk-[a-z0-9_-]{16,}\b/gi, REDACTED).replace(/\beyJ[a-z0-9_-]{20,}\.[a-z0-9_-]{20,}\.[a-z0-9_-]{20,}\b/gi, REDACTED);
|
|
3982
|
+
}
|
|
3983
|
+
//#endregion
|
|
3984
|
+
//#region src/tool-policy/sanitize.ts
|
|
3985
|
+
/**
|
|
3986
|
+
* What a plausible executable name looks like once the analyzer has stripped
|
|
3987
|
+
* any directory: a bounded token of portable filename characters, plus the
|
|
3988
|
+
* punctuation that names real shell builtins (`:`, `[`).
|
|
3989
|
+
*/
|
|
3990
|
+
var PLAUSIBLE_EXECUTABLE = /^[A-Za-z0-9._@+:[\]-]{1,64}$/;
|
|
3991
|
+
/** Stands in for a command name we will not reproduce. */
|
|
3992
|
+
var UNREPORTABLE_EXECUTABLE = "<unreportable>";
|
|
3993
|
+
/**
|
|
3994
|
+
* Bound a command name before it reaches a durable sink.
|
|
3995
|
+
*
|
|
3996
|
+
* The executable name is model-controlled: whatever the model writes in
|
|
3997
|
+
* command position becomes the "executable" the gate reports, and refusals are
|
|
3998
|
+
* persisted to the task record, the daemon log and the trace. Without this, a
|
|
3999
|
+
* secret written in command position is copied verbatim into all three, which
|
|
4000
|
+
* is exactly the content those records are supposed not to carry.
|
|
4001
|
+
*
|
|
4002
|
+
* Two bounds, in order:
|
|
4003
|
+
*
|
|
4004
|
+
* 1. Known credential shapes are redacted outright.
|
|
4005
|
+
* 2. Anything that does not look like an executable name — too long, or
|
|
4006
|
+
* outside portable filename characters — is replaced wholesale.
|
|
4007
|
+
*
|
|
4008
|
+
* Limits, stated plainly: this bounds the *shape* of what is reported. A short
|
|
4009
|
+
* secret made only of filename characters and matching no known token shape
|
|
4010
|
+
* still passes, and the {@link MissingShellCommand} fingerprint remains the
|
|
4011
|
+
* reliable identifier. Narrowing further would mean refusing to name the
|
|
4012
|
+
* program an operator needs to see, so this is the deliberate stopping point
|
|
4013
|
+
* until #1294's content-aware processing can be applied here too.
|
|
4014
|
+
*/
|
|
4015
|
+
function sanitizeExecutableName(name) {
|
|
4016
|
+
if (redactKnownSecretShapes(name) !== name) return UNREPORTABLE_EXECUTABLE;
|
|
4017
|
+
return PLAUSIBLE_EXECUTABLE.test(name) ? name : UNREPORTABLE_EXECUTABLE;
|
|
4018
|
+
}
|
|
4019
|
+
//#endregion
|
|
3949
4020
|
//#region src/tool-policy/gate.ts
|
|
3950
4021
|
/**
|
|
4022
|
+
* Environment variables that decide *which* binary an argv names, or inject
|
|
4023
|
+
* code into whichever one runs. A `VAR=value` prefix is not argv, so no
|
|
4024
|
+
* argv-prefix rule can describe it: `PATH=/tmp ls -la` matches a rule granting
|
|
4025
|
+
* `ls -la` while running an entirely different `ls`.
|
|
4026
|
+
*
|
|
4027
|
+
* Deliberately a short list of the loader, shell and interpreter entry points,
|
|
4028
|
+
* not a model of every program's configuration. Program-specific variables are
|
|
4029
|
+
* the same long tail as the analyzer's escape-flag table, and the ones git
|
|
4030
|
+
* honours are included because that table already models their `-c` twins.
|
|
4031
|
+
* What a process can reach once running is the sandbox's job (#2025).
|
|
4032
|
+
*/
|
|
4033
|
+
var UNSAFE_ENV_PREFIXES = ["LD_", "DYLD_"];
|
|
4034
|
+
var UNSAFE_ENV_NAMES = new Set([
|
|
4035
|
+
"PATH",
|
|
4036
|
+
"CDPATH",
|
|
4037
|
+
"IFS",
|
|
4038
|
+
"ENV",
|
|
4039
|
+
"BASH_ENV",
|
|
4040
|
+
"SHELLOPTS",
|
|
4041
|
+
"BASHOPTS",
|
|
4042
|
+
"PS4",
|
|
4043
|
+
"PYTHONPATH",
|
|
4044
|
+
"PYTHONSTARTUP",
|
|
4045
|
+
"PYTHONHOME",
|
|
4046
|
+
"PERL5OPT",
|
|
4047
|
+
"PERL5LIB",
|
|
4048
|
+
"RUBYOPT",
|
|
4049
|
+
"RUBYLIB",
|
|
4050
|
+
"NODE_OPTIONS",
|
|
4051
|
+
"CLASSPATH",
|
|
4052
|
+
"JAVA_TOOL_OPTIONS",
|
|
4053
|
+
"GIT_SSH",
|
|
4054
|
+
"GIT_SSH_COMMAND",
|
|
4055
|
+
"GIT_EXTERNAL_DIFF",
|
|
4056
|
+
"GIT_PAGER",
|
|
4057
|
+
"GIT_EDITOR",
|
|
4058
|
+
"GIT_SEQUENCE_EDITOR",
|
|
4059
|
+
"GIT_EXEC_PATH"
|
|
4060
|
+
]);
|
|
4061
|
+
function isUnsafeEnvName(name) {
|
|
4062
|
+
const upper = name.toUpperCase();
|
|
4063
|
+
return UNSAFE_ENV_NAMES.has(upper) || UNSAFE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
|
|
4064
|
+
}
|
|
4065
|
+
/**
|
|
3951
4066
|
* Decide whether a tool call is permitted by the resolved policy.
|
|
3952
4067
|
*
|
|
3953
4068
|
* Fail-closed in `enforce` (audited-but-allowed in `watch`, no-op in `off`) for:
|
|
@@ -3968,6 +4083,11 @@ function createGondolinBashOps(vm, localCwd, guestWorkspace, config) {
|
|
|
3968
4083
|
* 4. **Output redirection** — a `bash` command that redirects output (`>`,
|
|
3969
4084
|
* `2>`, `>>`, `&>`, …). No shell-command rule authorizes it; file writes go
|
|
3970
4085
|
* through structured tools.
|
|
4086
|
+
* 5. **Execution-redirecting environment prefixes** — a `VAR=value` prefix that
|
|
4087
|
+
* changes which binary argv names or injects code into it (`PATH`, `LD_*`,
|
|
4088
|
+
* `BASH_ENV`, …; see {@link UNSAFE_ENV_NAMES}). Such a prefix is not argv, so
|
|
4089
|
+
* the matched rule would describe a different program than the one that runs.
|
|
4090
|
+
* Benign assignments are unaffected.
|
|
3971
4091
|
*
|
|
3972
4092
|
* KNOWN LIMITATION (follow-up): the `escapable` risk tier (GTFOBins binaries
|
|
3973
4093
|
* like `find`, `tar`, `awk` that document shell-spawn / file-write techniques)
|
|
@@ -3988,10 +4108,12 @@ function decideToolCall(input) {
|
|
|
3988
4108
|
};
|
|
3989
4109
|
const resolved = resolveNames(input);
|
|
3990
4110
|
if (resolved.kind === "unresolvable") return fenced(input.enforcement, "shell_command_unresolvable", "shell command could not be statically authorized", "unresolvable shell command (watch)");
|
|
3991
|
-
const
|
|
4111
|
+
const unsafeEnv = (resolved.envAssignments ?? []).filter(isUnsafeEnvName);
|
|
4112
|
+
if (unsafeEnv.length > 0) return fenced(input.enforcement, "unsafe_environment_assignment", `environment assignment not permitted by tool policy: ${unsafeEnv.join(", ")}`, `would block — environment assignment (watch): ${unsafeEnv.join(", ")}`, unsafeEnv);
|
|
4113
|
+
const arbitraryCode = [...new Set(resolved.tools.filter((tool) => tool.risk === "arbitrary-code").map((tool) => sanitizeExecutableName(tool.name)))];
|
|
3992
4114
|
if (arbitraryCode.length > 0) return fenced(input.enforcement, "arbitrary_code_interpreter", `arbitrary-code interpreter not authorizable by tool policy: ${arbitraryCode.join(", ")}`, `would block — arbitrary-code interpreter (watch): ${arbitraryCode.join(", ")}`, arbitraryCode);
|
|
3993
4115
|
if (input.toolName !== "bash") {
|
|
3994
|
-
const missing = resolved.tools.
|
|
4116
|
+
const missing = resolved.tools.filter((tool) => !input.allowedTools.has(tool.name)).map((tool) => sanitizeExecutableName(tool.name));
|
|
3995
4117
|
if (missing.length === 0) return {
|
|
3996
4118
|
allow: true,
|
|
3997
4119
|
reasonCode: "policy_allowed"
|
|
@@ -4030,14 +4152,14 @@ function fingerprintArgv(argv) {
|
|
|
4030
4152
|
}
|
|
4031
4153
|
function toMatchedShellCommand(executable, argvPrefix) {
|
|
4032
4154
|
return {
|
|
4033
|
-
executable,
|
|
4155
|
+
executable: sanitizeExecutableName(executable),
|
|
4034
4156
|
argvPrefixFingerprint: fingerprintArgv(argvPrefix),
|
|
4035
4157
|
argvPrefixLength: argvPrefix.length
|
|
4036
4158
|
};
|
|
4037
4159
|
}
|
|
4038
4160
|
function toMissingShellCommand(tool) {
|
|
4039
4161
|
return {
|
|
4040
|
-
executable: tool.name,
|
|
4162
|
+
executable: sanitizeExecutableName(tool.name),
|
|
4041
4163
|
argvFingerprint: fingerprintArgv(tool.argv),
|
|
4042
4164
|
argvLength: tool.argv.length,
|
|
4043
4165
|
dynamicTokenCount: tool.argv.filter((token) => token === null).length
|
|
@@ -4093,12 +4215,94 @@ function resolveNames(input) {
|
|
|
4093
4215
|
argv: tool.argv,
|
|
4094
4216
|
risk: tool.risk
|
|
4095
4217
|
})),
|
|
4096
|
-
hasOutputRedirection: analysis.hasOutputRedirection === true
|
|
4218
|
+
hasOutputRedirection: analysis.hasOutputRedirection === true,
|
|
4219
|
+
envAssignments: analysis.envAssignments
|
|
4097
4220
|
} : {
|
|
4098
4221
|
kind: "unresolvable",
|
|
4099
4222
|
reason: analysis.reason
|
|
4100
4223
|
};
|
|
4101
4224
|
}
|
|
4225
|
+
//#endregion
|
|
4226
|
+
//#region src/tool-policy/telemetry.ts
|
|
4227
|
+
var METER_NAME$1 = "@themoltnet/pi-extension/tool-policy";
|
|
4228
|
+
/**
|
|
4229
|
+
* Counts every tool-policy decision.
|
|
4230
|
+
*
|
|
4231
|
+
* A decision reaches four sinks, each answering a different question:
|
|
4232
|
+
*
|
|
4233
|
+
* - **task record** (`tool_policy_decision` message) — the durable, per-attempt
|
|
4234
|
+
* evidence an operator reads to see what was refused and under which policy.
|
|
4235
|
+
* - **this counter** — aggregate rates and alerting. Bounded attributes only;
|
|
4236
|
+
* correlation ids deliberately live in the other three.
|
|
4237
|
+
* - **span** ({@link recordToolPolicyDecisionSpan}) — trace-level evidence. The
|
|
4238
|
+
* gate refuses at pi's `tool_call` event, earlier than the
|
|
4239
|
+
* `tool_execution_start` that creates `execute_tool`, so without it a blocked
|
|
4240
|
+
* call appears in no trace at all.
|
|
4241
|
+
* - **daemon log** — local diagnostics. Not exported today; see #2326.
|
|
4242
|
+
*
|
|
4243
|
+
* Allowed decisions are counted too — without the denominator a refusal count
|
|
4244
|
+
* cannot be read as a rate.
|
|
4245
|
+
*/
|
|
4246
|
+
var decisionCounter = null;
|
|
4247
|
+
function getDecisionCounter() {
|
|
4248
|
+
decisionCounter ??= metrics.getMeter(METER_NAME$1).createCounter("agent_runtime.tool_policy.decisions", {
|
|
4249
|
+
description: "Runtime tool-policy decisions by outcome, reason and enforcement mode.",
|
|
4250
|
+
unit: "1"
|
|
4251
|
+
});
|
|
4252
|
+
return decisionCounter;
|
|
4253
|
+
}
|
|
4254
|
+
/**
|
|
4255
|
+
* Attributes are deliberately low-cardinality: outcome, reason code,
|
|
4256
|
+
* enforcement mode and the degraded flag are all small closed sets. Task,
|
|
4257
|
+
* team, lease and tool-call ids are never attached — they are unbounded, and
|
|
4258
|
+
* the collector strips task ids from public metric datapoints anyway. The
|
|
4259
|
+
* per-decision detail lives in the task record instead.
|
|
4260
|
+
*/
|
|
4261
|
+
function recordToolPolicyDecisionMetric(metric) {
|
|
4262
|
+
try {
|
|
4263
|
+
getDecisionCounter().add(1, {
|
|
4264
|
+
decision: metric.decision,
|
|
4265
|
+
reason: metric.reason,
|
|
4266
|
+
enforcement: metric.enforcement,
|
|
4267
|
+
degraded: String(metric.degraded)
|
|
4268
|
+
});
|
|
4269
|
+
} catch {}
|
|
4270
|
+
}
|
|
4271
|
+
var TRACER_NAME = "@themoltnet/pi-extension/tool-policy";
|
|
4272
|
+
/**
|
|
4273
|
+
* Record a refusal on the trace.
|
|
4274
|
+
*
|
|
4275
|
+
* A blocked call has no span of its own otherwise: the gate refuses at pi's
|
|
4276
|
+
* `tool_call` event, which is earlier than the `tool_execution_start` that
|
|
4277
|
+
* creates `execute_tool`, so the call simply never appears. This span is that
|
|
4278
|
+
* missing evidence, parented to the live session span so it lands in the same
|
|
4279
|
+
* trace as the turn that attempted it.
|
|
4280
|
+
*
|
|
4281
|
+
* Unlike the counter, a span tolerates high cardinality, so it carries the
|
|
4282
|
+
* correlation fields the metric deliberately leaves off. It still carries no
|
|
4283
|
+
* argv literals — the executables and the record's fingerprints identify the
|
|
4284
|
+
* invocation without reproducing its arguments.
|
|
4285
|
+
*
|
|
4286
|
+
* The status is left UNSET on purpose: a refusal is the policy working, not a
|
|
4287
|
+
* system fault, and marking it ERROR would put correct behaviour in error views.
|
|
4288
|
+
*/
|
|
4289
|
+
function recordToolPolicyDecisionSpan(input, parentContext, correlation = {}) {
|
|
4290
|
+
try {
|
|
4291
|
+
const attributes = {
|
|
4292
|
+
...correlation,
|
|
4293
|
+
"moltnet.tool_policy.decision": input.decision,
|
|
4294
|
+
"moltnet.tool_policy.reason": input.reason_code,
|
|
4295
|
+
"moltnet.tool_policy.enforcement": input.enforcement,
|
|
4296
|
+
"moltnet.tool_policy.degraded": input.degraded,
|
|
4297
|
+
"gen_ai.tool.name": input.tool_name,
|
|
4298
|
+
...input.policy_snapshot_hash ? { "moltnet.tool_policy.snapshot_hash": input.policy_snapshot_hash } : {},
|
|
4299
|
+
...input.runtime_profile_revision !== void 0 ? { "moltnet.runtime_profile.revision": input.runtime_profile_revision } : {},
|
|
4300
|
+
...input.unauthorized_executables?.length ? { "moltnet.tool_policy.unauthorized_executables": input.unauthorized_executables } : {}
|
|
4301
|
+
};
|
|
4302
|
+
const tracer = trace.getTracer(TRACER_NAME);
|
|
4303
|
+
(parentContext ? tracer.startSpan("moltnet.tool_policy.decision", { attributes }, parentContext) : tracer.startSpan("moltnet.tool_policy.decision", { attributes })).end();
|
|
4304
|
+
} catch {}
|
|
4305
|
+
}
|
|
4102
4306
|
/**
|
|
4103
4307
|
* Resolve the session's tool policy at start-up.
|
|
4104
4308
|
*
|
|
@@ -4222,6 +4426,12 @@ function createToolPolicyExtension(deps) {
|
|
|
4222
4426
|
reason: decision.reasonCode,
|
|
4223
4427
|
...decision.matchedShellCommands?.length ? { shellFingerprints: decision.matchedShellCommands } : {}
|
|
4224
4428
|
}, "tool_policy.allowed");
|
|
4429
|
+
recordToolPolicyDecisionMetric({
|
|
4430
|
+
decision: "allowed",
|
|
4431
|
+
reason: decision.reasonCode,
|
|
4432
|
+
enforcement: deps.policy.enforcement,
|
|
4433
|
+
degraded: deps.policy.degraded === true
|
|
4434
|
+
});
|
|
4225
4435
|
return;
|
|
4226
4436
|
}
|
|
4227
4437
|
if ("audit" in decision) {
|
|
@@ -4231,9 +4441,10 @@ function createToolPolicyExtension(deps) {
|
|
|
4231
4441
|
toolCallId: event.toolCallId,
|
|
4232
4442
|
decision: "audit",
|
|
4233
4443
|
reason: decision.reasonCode,
|
|
4234
|
-
...decision.missing?.length ? {
|
|
4444
|
+
...decision.missing?.length ? { unauthorizedExecutables: decision.missing } : {},
|
|
4235
4445
|
...decision.missingShellCommands?.length ? { shellFingerprints: decision.missingShellCommands } : {}
|
|
4236
4446
|
}, "tool_policy.audit");
|
|
4447
|
+
reportDecision(deps, "would_block", event, decision);
|
|
4237
4448
|
return;
|
|
4238
4449
|
}
|
|
4239
4450
|
deps.logger.warn({
|
|
@@ -4242,9 +4453,10 @@ function createToolPolicyExtension(deps) {
|
|
|
4242
4453
|
toolCallId: event.toolCallId,
|
|
4243
4454
|
decision: "blocked",
|
|
4244
4455
|
reason: decision.reasonCode,
|
|
4245
|
-
...decision.missing?.length ? {
|
|
4456
|
+
...decision.missing?.length ? { unauthorizedExecutables: decision.missing } : {},
|
|
4246
4457
|
...decision.missingShellCommands?.length ? { shellFingerprints: decision.missingShellCommands } : {}
|
|
4247
4458
|
}, "tool_policy.blocked");
|
|
4459
|
+
reportDecision(deps, "blocked", event, decision);
|
|
4248
4460
|
return {
|
|
4249
4461
|
block: true,
|
|
4250
4462
|
reason: decision.reason
|
|
@@ -4252,6 +4464,37 @@ function createToolPolicyExtension(deps) {
|
|
|
4252
4464
|
});
|
|
4253
4465
|
};
|
|
4254
4466
|
}
|
|
4467
|
+
/**
|
|
4468
|
+
* Hand a refusal to {@link ToolPolicyExtensionDeps.onDecision}, never letting a
|
|
4469
|
+
* reporting failure change the gate's verdict.
|
|
4470
|
+
*/
|
|
4471
|
+
function reportDecision(deps, decision, event, gateDecision) {
|
|
4472
|
+
recordToolPolicyDecisionMetric({
|
|
4473
|
+
decision,
|
|
4474
|
+
reason: gateDecision.reasonCode,
|
|
4475
|
+
enforcement: deps.policy.enforcement,
|
|
4476
|
+
degraded: deps.policy.degraded === true
|
|
4477
|
+
});
|
|
4478
|
+
if (!deps.onDecision) return;
|
|
4479
|
+
const missing = "missing" in gateDecision ? gateDecision.missing : void 0;
|
|
4480
|
+
const shell = "missingShellCommands" in gateDecision ? gateDecision.missingShellCommands : void 0;
|
|
4481
|
+
try {
|
|
4482
|
+
deps.onDecision({
|
|
4483
|
+
decision,
|
|
4484
|
+
tool_name: event.toolName,
|
|
4485
|
+
...event.toolCallId ? { tool_call_id: event.toolCallId } : {},
|
|
4486
|
+
reason_code: gateDecision.reasonCode,
|
|
4487
|
+
enforcement: deps.policy.enforcement,
|
|
4488
|
+
...missing?.length ? { unauthorized_executables: missing } : {},
|
|
4489
|
+
...shell?.length ? { shell_fingerprints: shell } : {},
|
|
4490
|
+
degraded: deps.policy.degraded === true,
|
|
4491
|
+
...deps.policy.executionPolicySnapshotHash ? { policy_snapshot_hash: deps.policy.executionPolicySnapshotHash } : {},
|
|
4492
|
+
...deps.policy.executionRuntimeProfileRevision !== void 0 ? { runtime_profile_revision: deps.policy.executionRuntimeProfileRevision } : {}
|
|
4493
|
+
});
|
|
4494
|
+
} catch (error) {
|
|
4495
|
+
deps.logger.warn({ err: error instanceof Error ? error.message : String(error) }, "tool_policy.decision_report_failed");
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4255
4498
|
function decisionContext(deps) {
|
|
4256
4499
|
return {
|
|
4257
4500
|
...deps.context ?? {},
|
|
@@ -4401,7 +4644,6 @@ async function resolvePriorContext(agent, continueFrom) {
|
|
|
4401
4644
|
//#region src/runtime/retry-triage.ts
|
|
4402
4645
|
var MAX_TRIAGE_JSON_CHARS = 12e3;
|
|
4403
4646
|
var MAX_TRIAGE_FIELD_CHARS = 2e3;
|
|
4404
|
-
var REDACTED = "[redacted]";
|
|
4405
4647
|
var SECRET_KEY_PATTERN = /(?:api[_-]?key|token|secret|password|passwd|credential|authorization|private[_-]?key|access[_-]?token|refresh[_-]?token)/i;
|
|
4406
4648
|
function createPiRetryTriage(options) {
|
|
4407
4649
|
return async (input) => {
|
|
@@ -4534,7 +4776,7 @@ function redactAndTruncate(value, path) {
|
|
|
4534
4776
|
return value;
|
|
4535
4777
|
}
|
|
4536
4778
|
function redactRetryTriageSecrets(value) {
|
|
4537
|
-
return value
|
|
4779
|
+
return redactKnownSecretShapes(value);
|
|
4538
4780
|
}
|
|
4539
4781
|
function truncateString(value, maxChars) {
|
|
4540
4782
|
if (value.length <= maxChars) return value;
|
|
@@ -5517,6 +5759,7 @@ function summarizePayloadForLog(kind, payload) {
|
|
|
5517
5759
|
phase: payload.phase,
|
|
5518
5760
|
message: typeof payload.message === "string" ? payload.message.slice(0, LOG_TRUNCATE_LIMIT) : payload.message
|
|
5519
5761
|
};
|
|
5762
|
+
case "tool_policy_decision": return payload;
|
|
5520
5763
|
case "info": return Object.fromEntries(Object.entries(payload).map(([k, v]) => [k, typeof v === "string" ? v.slice(0, LOG_TRUNCATE_LIMIT) : v]));
|
|
5521
5764
|
default: return payload;
|
|
5522
5765
|
}
|
|
@@ -6053,6 +6296,7 @@ async function executePiTask(claimedTask, reporter, opts) {
|
|
|
6053
6296
|
let reporterOpen = opts.reporterAlreadyOpened ?? false;
|
|
6054
6297
|
let managed = null;
|
|
6055
6298
|
let session = null;
|
|
6299
|
+
const policyDecisionSink = createToolPolicyDecisionSink((record) => emit("tool_policy_decision", { ...record }));
|
|
6056
6300
|
let piSessionContext;
|
|
6057
6301
|
let providerRequestContext;
|
|
6058
6302
|
let subagentHandle = null;
|
|
@@ -6438,7 +6682,7 @@ async function executePiTask(claimedTask, reporter, opts) {
|
|
|
6438
6682
|
const piAuthDir = resolvePiCodingAgentDir();
|
|
6439
6683
|
const { modelHandle, modelRuntime } = await resolveRuntimeProfileModel(piAuthDir, opts.provider, opts.model, opts.runtimeProfileId);
|
|
6440
6684
|
const injectedSkills = injectedContext.skills;
|
|
6441
|
-
|
|
6685
|
+
let buildToolPolicyExtensions = () => [];
|
|
6442
6686
|
let resolvedToolPolicy;
|
|
6443
6687
|
let unavailableRuntimeShellCommands = [];
|
|
6444
6688
|
let verifiedGuestExecutables = [];
|
|
@@ -6477,12 +6721,24 @@ async function executePiTask(claimedTask, reporter, opts) {
|
|
|
6477
6721
|
...policy,
|
|
6478
6722
|
allowedShellCommands
|
|
6479
6723
|
};
|
|
6480
|
-
|
|
6481
|
-
|
|
6724
|
+
const sessionPolicy = resolvedToolPolicy;
|
|
6725
|
+
buildToolPolicyExtensions = (execution) => [createToolPolicyExtension({
|
|
6726
|
+
policy: sessionPolicy,
|
|
6482
6727
|
analyzer,
|
|
6483
6728
|
logger: toolPolicyLogger,
|
|
6484
|
-
context: toolPolicyDecisionContext
|
|
6485
|
-
|
|
6729
|
+
context: toolPolicyDecisionContext,
|
|
6730
|
+
onDecision: (record) => {
|
|
6731
|
+
recordToolPolicyDecisionSpan(record, piSessionContext, {
|
|
6732
|
+
"moltnet.task.id": task.id,
|
|
6733
|
+
"moltnet.task.attempt": attemptN,
|
|
6734
|
+
"moltnet.execution.kind": execution
|
|
6735
|
+
});
|
|
6736
|
+
policyDecisionSink.record({
|
|
6737
|
+
...record,
|
|
6738
|
+
execution
|
|
6739
|
+
});
|
|
6740
|
+
}
|
|
6741
|
+
})];
|
|
6486
6742
|
}
|
|
6487
6743
|
}
|
|
6488
6744
|
capabilityRouter?.setPolicy(resolvedToolPolicy ? {
|
|
@@ -6609,7 +6865,7 @@ async function executePiTask(claimedTask, reporter, opts) {
|
|
|
6609
6865
|
parentAttemptN: attemptN,
|
|
6610
6866
|
contractRegistry: opts.subagentContractRegistry,
|
|
6611
6867
|
parentCancelSignal: reporter.cancelSignal,
|
|
6612
|
-
extraExtensionFactories: [...runtimeSubagentExtensions, ...
|
|
6868
|
+
extraExtensionFactories: [...runtimeSubagentExtensions, ...buildToolPolicyExtensions("subagent")]
|
|
6613
6869
|
});
|
|
6614
6870
|
parentSubagentTools.push(subagentHandle.tool);
|
|
6615
6871
|
}
|
|
@@ -6658,7 +6914,7 @@ async function executePiTask(claimedTask, reporter, opts) {
|
|
|
6658
6914
|
sessionPersistence: executionPlan?.sessionPersistence ?? void 0,
|
|
6659
6915
|
extraExtensionFactories: [
|
|
6660
6916
|
...runtimeParentExtensions,
|
|
6661
|
-
...
|
|
6917
|
+
...buildToolPolicyExtensions("parent"),
|
|
6662
6918
|
submitCompletion.extension
|
|
6663
6919
|
]
|
|
6664
6920
|
}));
|
|
@@ -6783,6 +7039,7 @@ async function executePiTask(claimedTask, reporter, opts) {
|
|
|
6783
7039
|
event: "subagent_summary",
|
|
6784
7040
|
callCount: subagentHandle.getCallCount()
|
|
6785
7041
|
});
|
|
7042
|
+
await policyDecisionSink.drain();
|
|
6786
7043
|
await Promise.all([...recordingPromise, ...sandboxRetirementEvents]);
|
|
6787
7044
|
const cancelled = reporter.cancelSignal.aborted;
|
|
6788
7045
|
let parsedOutput = null;
|
|
@@ -6863,6 +7120,7 @@ async function executePiTask(claimedTask, reporter, opts) {
|
|
|
6863
7120
|
} catch (err) {
|
|
6864
7121
|
return makeFailedOutput("executor_unexpected_error", err instanceof Error ? err.message : String(err));
|
|
6865
7122
|
} finally {
|
|
7123
|
+
await policyDecisionSink.drain();
|
|
6866
7124
|
await cleanupAttempt({
|
|
6867
7125
|
cancelSignal: reporter.cancelSignal,
|
|
6868
7126
|
cancelListener,
|
package/dist/pi-config.d.ts
CHANGED
|
@@ -1,8 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every input modality Pi understands. Canonical for the repo: daemon
|
|
3
|
+
* validation, CLI parsing and wire schemas derive their allowed values from
|
|
4
|
+
* this list so they cannot drift from what Pi actually accepts.
|
|
5
|
+
*/
|
|
6
|
+
export declare const PI_MODEL_MODALITIES: readonly ["text", "image"];
|
|
7
|
+
|
|
8
|
+
/** Input modalities Pi understands for a model entry. */
|
|
9
|
+
export declare type PiModelModality = (typeof PI_MODEL_MODALITIES)[number];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A model entry in Pi's `models.json`. `input` declares the modalities the
|
|
13
|
+
* model accepts. Pi treats an entry with no `input` as text-only, so a vision
|
|
14
|
+
* model must declare `['text', 'image']` or image content parts never reach
|
|
15
|
+
* the provider.
|
|
16
|
+
*/
|
|
17
|
+
export declare interface PiModelSpec {
|
|
18
|
+
id: string;
|
|
19
|
+
input?: readonly PiModelModality[];
|
|
20
|
+
}
|
|
21
|
+
|
|
1
22
|
export declare interface WriteMultiProviderPiConfigInput extends WritePiConfigBase {
|
|
2
23
|
/** Provider registry keyed by Pi provider id. */
|
|
3
24
|
providers: Readonly<Record<string, WritePiProviderInput>>;
|
|
4
25
|
provider?: never;
|
|
5
26
|
model?: never;
|
|
27
|
+
input?: never;
|
|
6
28
|
baseUrl?: never;
|
|
7
29
|
apiKeyEnvRef?: never;
|
|
8
30
|
}
|
|
@@ -27,8 +49,8 @@ export declare interface WritePiProviderInput {
|
|
|
27
49
|
api: string;
|
|
28
50
|
/** Provider base URL. */
|
|
29
51
|
baseUrl: string;
|
|
30
|
-
/**
|
|
31
|
-
models: readonly
|
|
52
|
+
/** Models exposed by this provider. */
|
|
53
|
+
models: readonly PiModelSpec[];
|
|
32
54
|
/** Optional Pi environment placeholder, e.g. `$OLLAMA_API_KEY`. */
|
|
33
55
|
apiKeyEnvRef?: string;
|
|
34
56
|
}
|
|
@@ -38,6 +60,8 @@ export declare interface WriteSingleProviderPiConfigInput extends WritePiConfigB
|
|
|
38
60
|
provider: string;
|
|
39
61
|
/** Pi model id, e.g. `qwen3-coder:480b-cloud`. */
|
|
40
62
|
model: string;
|
|
63
|
+
/** Input modalities for `model`. Omitted leaves Pi's text-only default. */
|
|
64
|
+
input?: readonly PiModelModality[];
|
|
41
65
|
/**
|
|
42
66
|
* OpenAI-completions base URL for the provider. Defaults to Ollama Cloud.
|
|
43
67
|
*/
|
package/dist/pi-config.js
CHANGED
|
@@ -3,6 +3,22 @@ import { join } from "node:path";
|
|
|
3
3
|
//#region src/pi-config.ts
|
|
4
4
|
/** Node-only writer used by daemon serve runs and live evals. */
|
|
5
5
|
/**
|
|
6
|
+
* Every input modality Pi understands. Canonical for the repo: daemon
|
|
7
|
+
* validation, CLI parsing and wire schemas derive their allowed values from
|
|
8
|
+
* this list so they cannot drift from what Pi actually accepts.
|
|
9
|
+
*/
|
|
10
|
+
var PI_MODEL_MODALITIES = ["text", "image"];
|
|
11
|
+
/**
|
|
12
|
+
* Normalise a model entry to Pi's on-disk shape. `input` is emitted only when
|
|
13
|
+
* declared, so a text-only model serializes as a bare `{ id }`.
|
|
14
|
+
*/
|
|
15
|
+
function toPiModel(entry) {
|
|
16
|
+
return {
|
|
17
|
+
id: entry.id,
|
|
18
|
+
...entry.input && entry.input.length > 0 ? { input: [...entry.input] } : {}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
6
22
|
* Write Pi `models.json` + `settings.json`. Eval callers use the single-provider
|
|
7
23
|
* form so scores stay attributable; serve callers may supply many providers.
|
|
8
24
|
*/
|
|
@@ -12,12 +28,15 @@ function writePiConfig(input) {
|
|
|
12
28
|
api: provider.api,
|
|
13
29
|
...provider.apiKeyEnvRef ? { apiKey: provider.apiKeyEnvRef } : {},
|
|
14
30
|
baseUrl: provider.baseUrl,
|
|
15
|
-
models: provider.models.map(
|
|
31
|
+
models: provider.models.map(toPiModel)
|
|
16
32
|
}])) : { [input.provider]: {
|
|
17
33
|
api: "openai-completions",
|
|
18
34
|
apiKey: input.apiKeyEnvRef ?? "$OLLAMA_API_KEY",
|
|
19
35
|
baseUrl: input.baseUrl ?? "https://ollama.com/v1",
|
|
20
|
-
models: [{
|
|
36
|
+
models: [toPiModel({
|
|
37
|
+
id: input.model,
|
|
38
|
+
input: input.input
|
|
39
|
+
})]
|
|
21
40
|
} };
|
|
22
41
|
writeFileSync(join(input.piDir, "models.json"), JSON.stringify({ providers }, null, 2) + "\n", "utf8");
|
|
23
42
|
const defaultSettings = multiProvider ? { enableInstallTelemetry: false } : {
|
|
@@ -35,4 +54,4 @@ function writePiConfig(input) {
|
|
|
35
54
|
}, null, 2) + "\n", "utf8");
|
|
36
55
|
}
|
|
37
56
|
//#endregion
|
|
38
|
-
export { writePiConfig };
|
|
57
|
+
export { PI_MODEL_MODALITIES, writePiConfig };
|