@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 +34 -1
- package/dist/index.js +327 -31
- package/dist/testing.d.ts +87 -0
- package/dist/testing.js +884 -0
- package/package.json +12 -8
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
|
-
|
|
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({
|
|
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
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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 ? {
|
|
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 ? {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6481
|
-
|
|
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, ...
|
|
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
|
-
...
|
|
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,
|