@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 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 arbitraryCode = [...new Set(resolved.tools.filter((tool) => tool.risk === "arbitrary-code").map((tool) => tool.name))];
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.map((tool) => tool.name).filter((name) => !input.allowedTools.has(name));
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 ? { missingExecutables: decision.missing } : {},
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 ? { missingExecutables: decision.missing } : {},
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.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);
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
- const toolPolicyExtensions = [];
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
- toolPolicyExtensions.push(createToolPolicyExtension({
6481
- policy: resolvedToolPolicy,
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, ...toolPolicyExtensions]
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
- ...toolPolicyExtensions,
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,
@@ -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
- /** Model ids exposed by this provider. */
31
- models: readonly string[];
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((id) => ({ id }))
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: [{ id: input.model }]
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 };