@themoltnet/pi-runtime 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -253,11 +253,54 @@ var CREDENTIAL_SCOPES = {
253
253
  TaskManage: "task:manage",
254
254
  TaskRead: "task:read",
255
255
  TaskWrite: "task:write",
256
+ TeamJoin: "team:join",
256
257
  TeamManage: "team:manage",
257
258
  TeamRead: "team:read"
258
259
  };
259
260
  var ALL_CREDENTIAL_SCOPES = Object.freeze(Object.values(CREDENTIAL_SCOPES));
260
- CREDENTIAL_SCOPES.AgentProfile, CREDENTIAL_SCOPES.CryptoSign, CREDENTIAL_SCOPES.RuntimeRead, CREDENTIAL_SCOPES.TaskRead, CREDENTIAL_SCOPES.TaskClaim, CREDENTIAL_SCOPES.TaskExecute;
261
+ /**
262
+ * What the agent daemon cannot run without, checked against
263
+ * `GET /agents/whoami` at startup. Task credentials attenuate it further to
264
+ * `task:execute` alone.
265
+ *
266
+ * This is the **boot floor**, and deliberately not the same list as
267
+ * `AGENT_CREDENTIAL_SCOPES`. A credential's scopes are fixed when it is minted
268
+ * and `POST /agent-keys` caps a new key at the scopes of the credential
269
+ * requesting it, so no key can ever widen itself. A scope added here therefore
270
+ * stops every daemon already in the field, and only a human with a Console
271
+ * session can mint the replacement. Add one only when the daemon genuinely
272
+ * cannot work without it; anything a caller merely benefits from belongs in
273
+ * `DAEMON_OPTIONAL_SCOPES`, where absence costs a capability instead.
274
+ *
275
+ * `crypto:sign` is part of the minimum because host-capability signing runs on
276
+ * the daemon's own credential: the local seed signer calls the signing-request
277
+ * endpoints, which require it. A grant without it produces a daemon that boots
278
+ * cleanly and then fails the first time guest code signs a diary entry or a
279
+ * commit.
280
+ */
281
+ var DAEMON_MINIMUM_SCOPES = [
282
+ CREDENTIAL_SCOPES.AgentProfile,
283
+ CREDENTIAL_SCOPES.CryptoSign,
284
+ CREDENTIAL_SCOPES.RuntimeRead,
285
+ CREDENTIAL_SCOPES.TaskRead,
286
+ CREDENTIAL_SCOPES.TaskClaim,
287
+ CREDENTIAL_SCOPES.TaskExecute
288
+ ];
289
+ /**
290
+ * Read and enrollment authority a daemon uses when it has it, and runs without
291
+ * when it does not: reading the teams it belongs to and their diaries, and
292
+ * joining a team it is not yet a member of.
293
+ *
294
+ * Which product surface each one enables is deliberately not recorded here.
295
+ * That mapping belongs to whatever consumes the scope and changes with it,
296
+ * while the scope names are the contract and do not.
297
+ */
298
+ var DAEMON_OPTIONAL_SCOPES = [
299
+ CREDENTIAL_SCOPES.DiaryRead,
300
+ CREDENTIAL_SCOPES.TeamRead,
301
+ CREDENTIAL_SCOPES.TeamJoin
302
+ ];
303
+ [...DAEMON_MINIMUM_SCOPES, ...DAEMON_OPTIONAL_SCOPES];
261
304
  CREDENTIAL_SCOPES.AgentProfile, CREDENTIAL_SCOPES.TaskRead, CREDENTIAL_SCOPES.TaskWrite;
262
305
  CREDENTIAL_SCOPES.AgentProfile, CREDENTIAL_SCOPES.DiaryRead, CREDENTIAL_SCOPES.PackRead, CREDENTIAL_SCOPES.RuntimeRead, CREDENTIAL_SCOPES.TaskRead, CREDENTIAL_SCOPES.TeamRead;
263
306
  Object.freeze(ALL_CREDENTIAL_SCOPES.filter((scope) => scope !== CREDENTIAL_SCOPES.HumanProfile));
@@ -281,6 +324,7 @@ var MCP_CLIENT_SCOPES = [
281
324
  CREDENTIAL_SCOPES.TaskManage,
282
325
  CREDENTIAL_SCOPES.TaskRead,
283
326
  CREDENTIAL_SCOPES.TaskWrite,
327
+ CREDENTIAL_SCOPES.TeamJoin,
284
328
  CREDENTIAL_SCOPES.TeamManage,
285
329
  CREDENTIAL_SCOPES.TeamRead
286
330
  ];
@@ -634,17 +678,16 @@ Type.Object({
634
678
  Type.Literal("executor"),
635
679
  Type.Literal("member")
636
680
  ])),
637
- maxUses: Type.Optional(Type.Integer({
638
- minimum: 1,
639
- default: 1
640
- })),
641
681
  expiresInHours: Type.Optional(Type.Integer({
642
682
  minimum: 1,
643
683
  maximum: 720,
644
684
  default: 168
645
685
  }))
646
686
  });
647
- Type.Object({ code: Type.String({ minLength: 1 }) });
687
+ Type.Object({
688
+ code: Type.String({ minLength: 1 }),
689
+ issueAgentKey: Type.Optional(Type.Literal(true))
690
+ });
648
691
  Type.Object({ role: Type.Union([
649
692
  Type.Literal("manager"),
650
693
  Type.Literal("executor"),
@@ -669,8 +712,7 @@ Type.Object({
669
712
  Type.Literal("executor"),
670
713
  Type.Literal("member")
671
714
  ]),
672
- maxUses: Type.Integer(),
673
- useCount: Type.Integer(),
715
+ usedAt: Type.Union([Type.String({ format: "date-time" }), Type.Null()]),
674
716
  expiresAt: DateTimeUnsafe,
675
717
  createdAt: DateTimeUnsafe
676
718
  });
@@ -701,11 +743,7 @@ Type.Object({
701
743
  });
702
744
  Type.Object({
703
745
  teamId: UuidSchema,
704
- role: Type.Union([
705
- Type.Literal("manager"),
706
- Type.Literal("executor"),
707
- Type.Literal("member")
708
- ])
746
+ role: TeamRoleSchema
709
747
  });
710
748
  Type.Object({
711
749
  updated: Type.Boolean(),
@@ -2285,7 +2323,7 @@ function createMoltNetTools(config) {
2285
2323
  }
2286
2324
  //#endregion
2287
2325
  //#region src/otel/index.ts
2288
- var TRACER_NAME = "@themoltnet/pi-extension/otel";
2326
+ var TRACER_NAME$1 = "@themoltnet/pi-extension/otel";
2289
2327
  function stripReservedAttrs(attrs) {
2290
2328
  const out = {};
2291
2329
  for (const [k, v] of Object.entries(attrs)) {
@@ -2296,7 +2334,7 @@ function stripReservedAttrs(attrs) {
2296
2334
  }
2297
2335
  function createPiOtelExtension(options = {}) {
2298
2336
  return function piOtelExtension(pi) {
2299
- const tracer = trace.getTracer(TRACER_NAME);
2337
+ const tracer = trace.getTracer(TRACER_NAME$1);
2300
2338
  const extraAttrs = stripReservedAttrs(options.spanAttributes ?? {});
2301
2339
  let sessionSpan;
2302
2340
  let sessionCtx = context.active();
@@ -3946,8 +3984,123 @@ function createGondolinBashOps(vm, localCwd, guestWorkspace, config) {
3946
3984
  } };
3947
3985
  }
3948
3986
  //#endregion
3987
+ //#region src/tool-policy/decision-sink.ts
3988
+ function createToolPolicyDecisionSink(emit) {
3989
+ const pending = [];
3990
+ return {
3991
+ record(record) {
3992
+ try {
3993
+ pending.push(Promise.resolve(emit(record)).catch(() => void 0));
3994
+ } catch {}
3995
+ },
3996
+ async drain() {
3997
+ while (pending.length > 0) {
3998
+ const batch = pending.splice(0, pending.length);
3999
+ await Promise.all(batch);
4000
+ }
4001
+ },
4002
+ get size() {
4003
+ return pending.length;
4004
+ }
4005
+ };
4006
+ }
4007
+ //#endregion
4008
+ //#region src/redact.ts
4009
+ var REDACTED = "[redacted]";
4010
+ /**
4011
+ * Replace values matching well-known credential shapes.
4012
+ *
4013
+ * Shape matching only: it catches the common accident (a pasted token, a
4014
+ * bearer header, an `env` dump) and nothing else. It is not a guarantee that a
4015
+ * string is free of secrets, and must never be described as one. Content-aware
4016
+ * processing is #1294's job; this is the cheap layer that composes with it.
4017
+ */
4018
+ function redactKnownSecretShapes(value) {
4019
+ 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);
4020
+ }
4021
+ //#endregion
4022
+ //#region src/tool-policy/sanitize.ts
4023
+ /**
4024
+ * What a plausible executable name looks like once the analyzer has stripped
4025
+ * any directory: a bounded token of portable filename characters, plus the
4026
+ * punctuation that names real shell builtins (`:`, `[`).
4027
+ */
4028
+ var PLAUSIBLE_EXECUTABLE = /^[A-Za-z0-9._@+:[\]-]{1,64}$/;
4029
+ /** Stands in for a command name we will not reproduce. */
4030
+ var UNREPORTABLE_EXECUTABLE = "<unreportable>";
4031
+ /**
4032
+ * Bound a command name before it reaches a durable sink.
4033
+ *
4034
+ * The executable name is model-controlled: whatever the model writes in
4035
+ * command position becomes the "executable" the gate reports, and refusals are
4036
+ * persisted to the task record, the daemon log and the trace. Without this, a
4037
+ * secret written in command position is copied verbatim into all three, which
4038
+ * is exactly the content those records are supposed not to carry.
4039
+ *
4040
+ * Two bounds, in order:
4041
+ *
4042
+ * 1. Known credential shapes are redacted outright.
4043
+ * 2. Anything that does not look like an executable name — too long, or
4044
+ * outside portable filename characters — is replaced wholesale.
4045
+ *
4046
+ * Limits, stated plainly: this bounds the *shape* of what is reported. A short
4047
+ * secret made only of filename characters and matching no known token shape
4048
+ * still passes, and the {@link MissingShellCommand} fingerprint remains the
4049
+ * reliable identifier. Narrowing further would mean refusing to name the
4050
+ * program an operator needs to see, so this is the deliberate stopping point
4051
+ * until #1294's content-aware processing can be applied here too.
4052
+ */
4053
+ function sanitizeExecutableName(name) {
4054
+ if (redactKnownSecretShapes(name) !== name) return UNREPORTABLE_EXECUTABLE;
4055
+ return PLAUSIBLE_EXECUTABLE.test(name) ? name : UNREPORTABLE_EXECUTABLE;
4056
+ }
4057
+ //#endregion
3949
4058
  //#region src/tool-policy/gate.ts
3950
4059
  /**
4060
+ * Environment variables that decide *which* binary an argv names, or inject
4061
+ * code into whichever one runs. A `VAR=value` prefix is not argv, so no
4062
+ * argv-prefix rule can describe it: `PATH=/tmp ls -la` matches a rule granting
4063
+ * `ls -la` while running an entirely different `ls`.
4064
+ *
4065
+ * Deliberately a short list of the loader, shell and interpreter entry points,
4066
+ * not a model of every program's configuration. Program-specific variables are
4067
+ * the same long tail as the analyzer's escape-flag table, and the ones git
4068
+ * honours are included because that table already models their `-c` twins.
4069
+ * What a process can reach once running is the sandbox's job (#2025).
4070
+ */
4071
+ var UNSAFE_ENV_PREFIXES = ["LD_", "DYLD_"];
4072
+ var UNSAFE_ENV_NAMES = new Set([
4073
+ "PATH",
4074
+ "CDPATH",
4075
+ "IFS",
4076
+ "ENV",
4077
+ "BASH_ENV",
4078
+ "SHELLOPTS",
4079
+ "BASHOPTS",
4080
+ "PS4",
4081
+ "PYTHONPATH",
4082
+ "PYTHONSTARTUP",
4083
+ "PYTHONHOME",
4084
+ "PERL5OPT",
4085
+ "PERL5LIB",
4086
+ "RUBYOPT",
4087
+ "RUBYLIB",
4088
+ "NODE_OPTIONS",
4089
+ "CLASSPATH",
4090
+ "JAVA_TOOL_OPTIONS",
4091
+ "GIT_SSH",
4092
+ "GIT_SSH_COMMAND",
4093
+ "GIT_EXTERNAL_DIFF",
4094
+ "GIT_PAGER",
4095
+ "GIT_EDITOR",
4096
+ "GIT_SEQUENCE_EDITOR",
4097
+ "GIT_EXEC_PATH"
4098
+ ]);
4099
+ function isUnsafeEnvName(name) {
4100
+ const upper = name.toUpperCase();
4101
+ return UNSAFE_ENV_NAMES.has(upper) || UNSAFE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
4102
+ }
4103
+ /**
3951
4104
  * Decide whether a tool call is permitted by the resolved policy.
3952
4105
  *
3953
4106
  * Fail-closed in `enforce` (audited-but-allowed in `watch`, no-op in `off`) for:
@@ -3968,6 +4121,11 @@ function createGondolinBashOps(vm, localCwd, guestWorkspace, config) {
3968
4121
  * 4. **Output redirection** — a `bash` command that redirects output (`>`,
3969
4122
  * `2>`, `>>`, `&>`, …). No shell-command rule authorizes it; file writes go
3970
4123
  * through structured tools.
4124
+ * 5. **Execution-redirecting environment prefixes** — a `VAR=value` prefix that
4125
+ * changes which binary argv names or injects code into it (`PATH`, `LD_*`,
4126
+ * `BASH_ENV`, …; see {@link UNSAFE_ENV_NAMES}). Such a prefix is not argv, so
4127
+ * the matched rule would describe a different program than the one that runs.
4128
+ * Benign assignments are unaffected.
3971
4129
  *
3972
4130
  * KNOWN LIMITATION (follow-up): the `escapable` risk tier (GTFOBins binaries
3973
4131
  * like `find`, `tar`, `awk` that document shell-spawn / file-write techniques)
@@ -3988,10 +4146,12 @@ function decideToolCall(input) {
3988
4146
  };
3989
4147
  const resolved = resolveNames(input);
3990
4148
  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))];
4149
+ const unsafeEnv = (resolved.envAssignments ?? []).filter(isUnsafeEnvName);
4150
+ 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);
4151
+ const arbitraryCode = [...new Set(resolved.tools.filter((tool) => tool.risk === "arbitrary-code").map((tool) => sanitizeExecutableName(tool.name)))];
3992
4152
  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
4153
  if (input.toolName !== "bash") {
3994
- const missing = resolved.tools.map((tool) => tool.name).filter((name) => !input.allowedTools.has(name));
4154
+ const missing = resolved.tools.filter((tool) => !input.allowedTools.has(tool.name)).map((tool) => sanitizeExecutableName(tool.name));
3995
4155
  if (missing.length === 0) return {
3996
4156
  allow: true,
3997
4157
  reasonCode: "policy_allowed"
@@ -4030,14 +4190,14 @@ function fingerprintArgv(argv) {
4030
4190
  }
4031
4191
  function toMatchedShellCommand(executable, argvPrefix) {
4032
4192
  return {
4033
- executable,
4193
+ executable: sanitizeExecutableName(executable),
4034
4194
  argvPrefixFingerprint: fingerprintArgv(argvPrefix),
4035
4195
  argvPrefixLength: argvPrefix.length
4036
4196
  };
4037
4197
  }
4038
4198
  function toMissingShellCommand(tool) {
4039
4199
  return {
4040
- executable: tool.name,
4200
+ executable: sanitizeExecutableName(tool.name),
4041
4201
  argvFingerprint: fingerprintArgv(tool.argv),
4042
4202
  argvLength: tool.argv.length,
4043
4203
  dynamicTokenCount: tool.argv.filter((token) => token === null).length
@@ -4093,12 +4253,94 @@ function resolveNames(input) {
4093
4253
  argv: tool.argv,
4094
4254
  risk: tool.risk
4095
4255
  })),
4096
- hasOutputRedirection: analysis.hasOutputRedirection === true
4256
+ hasOutputRedirection: analysis.hasOutputRedirection === true,
4257
+ envAssignments: analysis.envAssignments
4097
4258
  } : {
4098
4259
  kind: "unresolvable",
4099
4260
  reason: analysis.reason
4100
4261
  };
4101
4262
  }
4263
+ //#endregion
4264
+ //#region src/tool-policy/telemetry.ts
4265
+ var METER_NAME$1 = "@themoltnet/pi-extension/tool-policy";
4266
+ /**
4267
+ * Counts every tool-policy decision.
4268
+ *
4269
+ * A decision reaches four sinks, each answering a different question:
4270
+ *
4271
+ * - **task record** (`tool_policy_decision` message) — the durable, per-attempt
4272
+ * evidence an operator reads to see what was refused and under which policy.
4273
+ * - **this counter** — aggregate rates and alerting. Bounded attributes only;
4274
+ * correlation ids deliberately live in the other three.
4275
+ * - **span** ({@link recordToolPolicyDecisionSpan}) — trace-level evidence. The
4276
+ * gate refuses at pi's `tool_call` event, earlier than the
4277
+ * `tool_execution_start` that creates `execute_tool`, so without it a blocked
4278
+ * call appears in no trace at all.
4279
+ * - **daemon log** — local diagnostics. Not exported today; see #2326.
4280
+ *
4281
+ * Allowed decisions are counted too — without the denominator a refusal count
4282
+ * cannot be read as a rate.
4283
+ */
4284
+ var decisionCounter = null;
4285
+ function getDecisionCounter() {
4286
+ decisionCounter ??= metrics.getMeter(METER_NAME$1).createCounter("agent_runtime.tool_policy.decisions", {
4287
+ description: "Runtime tool-policy decisions by outcome, reason and enforcement mode.",
4288
+ unit: "1"
4289
+ });
4290
+ return decisionCounter;
4291
+ }
4292
+ /**
4293
+ * Attributes are deliberately low-cardinality: outcome, reason code,
4294
+ * enforcement mode and the degraded flag are all small closed sets. Task,
4295
+ * team, lease and tool-call ids are never attached — they are unbounded, and
4296
+ * the collector strips task ids from public metric datapoints anyway. The
4297
+ * per-decision detail lives in the task record instead.
4298
+ */
4299
+ function recordToolPolicyDecisionMetric(metric) {
4300
+ try {
4301
+ getDecisionCounter().add(1, {
4302
+ decision: metric.decision,
4303
+ reason: metric.reason,
4304
+ enforcement: metric.enforcement,
4305
+ degraded: String(metric.degraded)
4306
+ });
4307
+ } catch {}
4308
+ }
4309
+ var TRACER_NAME = "@themoltnet/pi-extension/tool-policy";
4310
+ /**
4311
+ * Record a refusal on the trace.
4312
+ *
4313
+ * A blocked call has no span of its own otherwise: the gate refuses at pi's
4314
+ * `tool_call` event, which is earlier than the `tool_execution_start` that
4315
+ * creates `execute_tool`, so the call simply never appears. This span is that
4316
+ * missing evidence, parented to the live session span so it lands in the same
4317
+ * trace as the turn that attempted it.
4318
+ *
4319
+ * Unlike the counter, a span tolerates high cardinality, so it carries the
4320
+ * correlation fields the metric deliberately leaves off. It still carries no
4321
+ * argv literals — the executables and the record's fingerprints identify the
4322
+ * invocation without reproducing its arguments.
4323
+ *
4324
+ * The status is left UNSET on purpose: a refusal is the policy working, not a
4325
+ * system fault, and marking it ERROR would put correct behaviour in error views.
4326
+ */
4327
+ function recordToolPolicyDecisionSpan(input, parentContext, correlation = {}) {
4328
+ try {
4329
+ const attributes = {
4330
+ ...correlation,
4331
+ "moltnet.tool_policy.decision": input.decision,
4332
+ "moltnet.tool_policy.reason": input.reason_code,
4333
+ "moltnet.tool_policy.enforcement": input.enforcement,
4334
+ "moltnet.tool_policy.degraded": input.degraded,
4335
+ "gen_ai.tool.name": input.tool_name,
4336
+ ...input.policy_snapshot_hash ? { "moltnet.tool_policy.snapshot_hash": input.policy_snapshot_hash } : {},
4337
+ ...input.runtime_profile_revision !== void 0 ? { "moltnet.runtime_profile.revision": input.runtime_profile_revision } : {},
4338
+ ...input.unauthorized_executables?.length ? { "moltnet.tool_policy.unauthorized_executables": input.unauthorized_executables } : {}
4339
+ };
4340
+ const tracer = trace.getTracer(TRACER_NAME);
4341
+ (parentContext ? tracer.startSpan("moltnet.tool_policy.decision", { attributes }, parentContext) : tracer.startSpan("moltnet.tool_policy.decision", { attributes })).end();
4342
+ } catch {}
4343
+ }
4102
4344
  /**
4103
4345
  * Resolve the session's tool policy at start-up.
4104
4346
  *
@@ -4222,6 +4464,12 @@ function createToolPolicyExtension(deps) {
4222
4464
  reason: decision.reasonCode,
4223
4465
  ...decision.matchedShellCommands?.length ? { shellFingerprints: decision.matchedShellCommands } : {}
4224
4466
  }, "tool_policy.allowed");
4467
+ recordToolPolicyDecisionMetric({
4468
+ decision: "allowed",
4469
+ reason: decision.reasonCode,
4470
+ enforcement: deps.policy.enforcement,
4471
+ degraded: deps.policy.degraded === true
4472
+ });
4225
4473
  return;
4226
4474
  }
4227
4475
  if ("audit" in decision) {
@@ -4231,9 +4479,10 @@ function createToolPolicyExtension(deps) {
4231
4479
  toolCallId: event.toolCallId,
4232
4480
  decision: "audit",
4233
4481
  reason: decision.reasonCode,
4234
- ...decision.missing?.length ? { missingExecutables: decision.missing } : {},
4482
+ ...decision.missing?.length ? { unauthorizedExecutables: decision.missing } : {},
4235
4483
  ...decision.missingShellCommands?.length ? { shellFingerprints: decision.missingShellCommands } : {}
4236
4484
  }, "tool_policy.audit");
4485
+ reportDecision(deps, "would_block", event, decision);
4237
4486
  return;
4238
4487
  }
4239
4488
  deps.logger.warn({
@@ -4242,9 +4491,10 @@ function createToolPolicyExtension(deps) {
4242
4491
  toolCallId: event.toolCallId,
4243
4492
  decision: "blocked",
4244
4493
  reason: decision.reasonCode,
4245
- ...decision.missing?.length ? { missingExecutables: decision.missing } : {},
4494
+ ...decision.missing?.length ? { unauthorizedExecutables: decision.missing } : {},
4246
4495
  ...decision.missingShellCommands?.length ? { shellFingerprints: decision.missingShellCommands } : {}
4247
4496
  }, "tool_policy.blocked");
4497
+ reportDecision(deps, "blocked", event, decision);
4248
4498
  return {
4249
4499
  block: true,
4250
4500
  reason: decision.reason
@@ -4252,6 +4502,37 @@ function createToolPolicyExtension(deps) {
4252
4502
  });
4253
4503
  };
4254
4504
  }
4505
+ /**
4506
+ * Hand a refusal to {@link ToolPolicyExtensionDeps.onDecision}, never letting a
4507
+ * reporting failure change the gate's verdict.
4508
+ */
4509
+ function reportDecision(deps, decision, event, gateDecision) {
4510
+ recordToolPolicyDecisionMetric({
4511
+ decision,
4512
+ reason: gateDecision.reasonCode,
4513
+ enforcement: deps.policy.enforcement,
4514
+ degraded: deps.policy.degraded === true
4515
+ });
4516
+ if (!deps.onDecision) return;
4517
+ const missing = "missing" in gateDecision ? gateDecision.missing : void 0;
4518
+ const shell = "missingShellCommands" in gateDecision ? gateDecision.missingShellCommands : void 0;
4519
+ try {
4520
+ deps.onDecision({
4521
+ decision,
4522
+ tool_name: event.toolName,
4523
+ ...event.toolCallId ? { tool_call_id: event.toolCallId } : {},
4524
+ reason_code: gateDecision.reasonCode,
4525
+ enforcement: deps.policy.enforcement,
4526
+ ...missing?.length ? { unauthorized_executables: missing } : {},
4527
+ ...shell?.length ? { shell_fingerprints: shell } : {},
4528
+ degraded: deps.policy.degraded === true,
4529
+ ...deps.policy.executionPolicySnapshotHash ? { policy_snapshot_hash: deps.policy.executionPolicySnapshotHash } : {},
4530
+ ...deps.policy.executionRuntimeProfileRevision !== void 0 ? { runtime_profile_revision: deps.policy.executionRuntimeProfileRevision } : {}
4531
+ });
4532
+ } catch (error) {
4533
+ deps.logger.warn({ err: error instanceof Error ? error.message : String(error) }, "tool_policy.decision_report_failed");
4534
+ }
4535
+ }
4255
4536
  function decisionContext(deps) {
4256
4537
  return {
4257
4538
  ...deps.context ?? {},
@@ -4401,7 +4682,6 @@ async function resolvePriorContext(agent, continueFrom) {
4401
4682
  //#region src/runtime/retry-triage.ts
4402
4683
  var MAX_TRIAGE_JSON_CHARS = 12e3;
4403
4684
  var MAX_TRIAGE_FIELD_CHARS = 2e3;
4404
- var REDACTED = "[redacted]";
4405
4685
  var SECRET_KEY_PATTERN = /(?:api[_-]?key|token|secret|password|passwd|credential|authorization|private[_-]?key|access[_-]?token|refresh[_-]?token)/i;
4406
4686
  function createPiRetryTriage(options) {
4407
4687
  return async (input) => {
@@ -4534,7 +4814,7 @@ function redactAndTruncate(value, path) {
4534
4814
  return value;
4535
4815
  }
4536
4816
  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);
4817
+ return redactKnownSecretShapes(value);
4538
4818
  }
4539
4819
  function truncateString(value, maxChars) {
4540
4820
  if (value.length <= maxChars) return value;
@@ -5517,6 +5797,7 @@ function summarizePayloadForLog(kind, payload) {
5517
5797
  phase: payload.phase,
5518
5798
  message: typeof payload.message === "string" ? payload.message.slice(0, LOG_TRUNCATE_LIMIT) : payload.message
5519
5799
  };
5800
+ case "tool_policy_decision": return payload;
5520
5801
  case "info": return Object.fromEntries(Object.entries(payload).map(([k, v]) => [k, typeof v === "string" ? v.slice(0, LOG_TRUNCATE_LIMIT) : v]));
5521
5802
  default: return payload;
5522
5803
  }
@@ -6053,6 +6334,7 @@ async function executePiTask(claimedTask, reporter, opts) {
6053
6334
  let reporterOpen = opts.reporterAlreadyOpened ?? false;
6054
6335
  let managed = null;
6055
6336
  let session = null;
6337
+ const policyDecisionSink = createToolPolicyDecisionSink((record) => emit("tool_policy_decision", { ...record }));
6056
6338
  let piSessionContext;
6057
6339
  let providerRequestContext;
6058
6340
  let subagentHandle = null;
@@ -6438,7 +6720,7 @@ async function executePiTask(claimedTask, reporter, opts) {
6438
6720
  const piAuthDir = resolvePiCodingAgentDir();
6439
6721
  const { modelHandle, modelRuntime } = await resolveRuntimeProfileModel(piAuthDir, opts.provider, opts.model, opts.runtimeProfileId);
6440
6722
  const injectedSkills = injectedContext.skills;
6441
- const toolPolicyExtensions = [];
6723
+ let buildToolPolicyExtensions = () => [];
6442
6724
  let resolvedToolPolicy;
6443
6725
  let unavailableRuntimeShellCommands = [];
6444
6726
  let verifiedGuestExecutables = [];
@@ -6477,12 +6759,24 @@ async function executePiTask(claimedTask, reporter, opts) {
6477
6759
  ...policy,
6478
6760
  allowedShellCommands
6479
6761
  };
6480
- toolPolicyExtensions.push(createToolPolicyExtension({
6481
- policy: resolvedToolPolicy,
6762
+ const sessionPolicy = resolvedToolPolicy;
6763
+ buildToolPolicyExtensions = (execution) => [createToolPolicyExtension({
6764
+ policy: sessionPolicy,
6482
6765
  analyzer,
6483
6766
  logger: toolPolicyLogger,
6484
- context: toolPolicyDecisionContext
6485
- }));
6767
+ context: toolPolicyDecisionContext,
6768
+ onDecision: (record) => {
6769
+ recordToolPolicyDecisionSpan(record, piSessionContext, {
6770
+ "moltnet.task.id": task.id,
6771
+ "moltnet.task.attempt": attemptN,
6772
+ "moltnet.execution.kind": execution
6773
+ });
6774
+ policyDecisionSink.record({
6775
+ ...record,
6776
+ execution
6777
+ });
6778
+ }
6779
+ })];
6486
6780
  }
6487
6781
  }
6488
6782
  capabilityRouter?.setPolicy(resolvedToolPolicy ? {
@@ -6609,7 +6903,7 @@ async function executePiTask(claimedTask, reporter, opts) {
6609
6903
  parentAttemptN: attemptN,
6610
6904
  contractRegistry: opts.subagentContractRegistry,
6611
6905
  parentCancelSignal: reporter.cancelSignal,
6612
- extraExtensionFactories: [...runtimeSubagentExtensions, ...toolPolicyExtensions]
6906
+ extraExtensionFactories: [...runtimeSubagentExtensions, ...buildToolPolicyExtensions("subagent")]
6613
6907
  });
6614
6908
  parentSubagentTools.push(subagentHandle.tool);
6615
6909
  }
@@ -6658,7 +6952,7 @@ async function executePiTask(claimedTask, reporter, opts) {
6658
6952
  sessionPersistence: executionPlan?.sessionPersistence ?? void 0,
6659
6953
  extraExtensionFactories: [
6660
6954
  ...runtimeParentExtensions,
6661
- ...toolPolicyExtensions,
6955
+ ...buildToolPolicyExtensions("parent"),
6662
6956
  submitCompletion.extension
6663
6957
  ]
6664
6958
  }));
@@ -6783,6 +7077,7 @@ async function executePiTask(claimedTask, reporter, opts) {
6783
7077
  event: "subagent_summary",
6784
7078
  callCount: subagentHandle.getCallCount()
6785
7079
  });
7080
+ await policyDecisionSink.drain();
6786
7081
  await Promise.all([...recordingPromise, ...sandboxRetirementEvents]);
6787
7082
  const cancelled = reporter.cancelSignal.aborted;
6788
7083
  let parsedOutput = null;
@@ -6863,6 +7158,7 @@ async function executePiTask(claimedTask, reporter, opts) {
6863
7158
  } catch (err) {
6864
7159
  return makeFailedOutput("executor_unexpected_error", err instanceof Error ? err.message : String(err));
6865
7160
  } finally {
7161
+ await policyDecisionSink.drain();
6866
7162
  await cleanupAttempt({
6867
7163
  cancelSignal: reporter.cancelSignal,
6868
7164
  cancelListener,