@themoltnet/pi-runtime 0.2.0 → 0.3.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
@@ -43,6 +43,9 @@ export declare interface AllowedToolsClient {
43
43
  }) => Promise<{
44
44
  enforcement: ToolEnforcement;
45
45
  allowedTools: string[];
46
+ allowedShellCommands: Array<{
47
+ argvPrefix: string[];
48
+ }>;
46
49
  }>;
47
50
  };
48
51
  }
@@ -591,12 +594,16 @@ export declare function findMainWorktree(startPath?: string): string;
591
594
  */
592
595
  export declare type GateDecision = {
593
596
  allow: true;
597
+ matchedShellCommands?: MatchedShellCommand[];
594
598
  } | {
595
599
  allow: false;
596
600
  reason: string;
601
+ missing?: string[];
602
+ missingShellCommands?: MissingShellCommand[];
597
603
  } | {
598
604
  audit: string;
599
605
  missing?: string[];
606
+ missingShellCommands?: MissingShellCommand[];
600
607
  };
601
608
 
602
609
  export declare interface GateInput {
@@ -607,6 +614,8 @@ export declare interface GateInput {
607
614
  enforcement: ToolEnforcement;
608
615
  /** Names the policy allows (structured tool names + shell executable names). */
609
616
  allowedTools: ReadonlySet<string>;
617
+ /** Shell argv rules. Each rule matches the first N statically known tokens. */
618
+ allowedShellCommands: readonly ShellCommandRule[];
610
619
  /**
611
620
  * Synchronous shell analyzer (`ShellCommandAnalyzer.analyze`). Injected so the
612
621
  * decision stays pure and testable; the analyzer's async WASM init happens
@@ -694,6 +703,12 @@ export declare interface ManagedVm {
694
703
  agentDir: string;
695
704
  }
696
705
 
706
+ declare interface MatchedShellCommand {
707
+ executable: string;
708
+ argvPrefixFingerprint: string;
709
+ argvPrefixLength: number;
710
+ }
711
+
697
712
  export declare function materializePiExtensions(input: {
698
713
  runtime: PiRuntimeDefinition;
699
714
  context: PiToolContext;
@@ -714,6 +729,18 @@ export declare function materializePiTools(input: {
714
729
  };
715
730
  }): Promise<ToolDefinition[]>;
716
731
 
732
+ /**
733
+ * Literal-free metadata for a shell invocation that did not match policy.
734
+ * argv values are deliberately omitted; the fingerprint distinguishes
735
+ * invocations without placing literal arguments in the decision.
736
+ */
737
+ declare interface MissingShellCommand {
738
+ executable: string;
739
+ argvFingerprint: string;
740
+ argvLength: number;
741
+ dynamicTokenCount: number;
742
+ }
743
+
717
744
  export declare const MOLTNET_TOOL_NAMES: readonly ["moltnet_pack_get", "moltnet_pack_create", "moltnet_pack_provenance", "moltnet_pack_render", "moltnet_rendered_pack_list", "moltnet_rendered_pack_get", "moltnet_diary_tags", "moltnet_list_entries", "moltnet_get_entry", "moltnet_search_entries", "moltnet_create_entry", "moltnet_get_task", "moltnet_list_task_attempts", "moltnet_list_task_messages", "moltnet_upload_task_artifact", "moltnet_list_task_artifacts", "moltnet_download_task_artifact", "moltnet_review_session_errors", "moltnet_host_exec"];
718
745
 
719
746
  declare type MoltNetAgent = Awaited<ReturnType<typeof connect>>;
@@ -1183,6 +1210,7 @@ export declare interface SandboxConfig {
1183
1210
  export declare interface SessionToolPolicy {
1184
1211
  enforcement: ToolEnforcement;
1185
1212
  allowedTools: ReadonlySet<string>;
1213
+ allowedShellCommands: readonly ShellCommandRule[];
1186
1214
  /**
1187
1215
  * `true` when the allow-set is a **degraded fallback** — the allowed-tools
1188
1216
  * fetch failed or timed out and this policy is the fail-closed/fail-open
@@ -1195,6 +1223,10 @@ export declare interface SessionToolPolicy {
1195
1223
  degraded: boolean;
1196
1224
  }
1197
1225
 
1226
+ declare interface ShellCommandRule {
1227
+ argvPrefix: readonly [string, string, ...string[]];
1228
+ }
1229
+
1198
1230
  /** Extract snapshot-specific config for backwards compat with ensureSnapshot. */
1199
1231
  export declare type SnapshotConfig = NonNullable<SandboxConfig['snapshot']>;
1200
1232
 
package/dist/index.js CHANGED
@@ -30549,6 +30549,10 @@ var FIND_EXEC_FLAGS = new Set([
30549
30549
  "-okdir"
30550
30550
  ]);
30551
30551
  var ESCAPE_FLAG_SPECS = new Map([
30552
+ ["env", {
30553
+ separate: ["-S", "--split-string"],
30554
+ inline: [{ flag: "--split-string" }]
30555
+ }],
30552
30556
  ["tar", { inline: [{ flag: "--to-command" }, {
30553
30557
  flag: "--checkpoint-action",
30554
30558
  valuePrefix: "exec="
@@ -30611,6 +30615,7 @@ function resolveDefaultBashWasm() {
30611
30615
  var DEFAULT_MAX_COMMAND_LENGTH = 1e5;
30612
30616
  var ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
30613
30617
  var GLOB_RE = /[*?[\]]/;
30618
+ var WHITESPACE_RE = /\s/u;
30614
30619
  var SUBSTITUTION_TYPES = ["command_substitution", "process_substitution"];
30615
30620
  /** Guards against pathological nesting of escape-flag values. */
30616
30621
  var MAX_ESCAPE_DEPTH = 3;
@@ -30620,11 +30625,22 @@ function baseName(raw) {
30620
30625
  const slash = raw.lastIndexOf("/");
30621
30626
  return slash === -1 ? raw : raw.slice(slash + 1);
30622
30627
  }
30628
+ /** Build the public argv vector for one resolved invocation. */
30629
+ function invocationFor(words) {
30630
+ const head = words[0]?.name;
30631
+ if (head === null || head === void 0) return null;
30632
+ const name = baseName(head);
30633
+ return {
30634
+ name,
30635
+ argv: [name, ...words.slice(1).map((word) => word.name)]
30636
+ };
30637
+ }
30623
30638
  /** Classify one AST node into a {@link Word}. */
30624
30639
  function classifyToken(node) {
30625
30640
  const raw = node.text;
30626
30641
  switch (node.type) {
30627
- case "word": return {
30642
+ case "word":
30643
+ case "number": return {
30628
30644
  raw,
30629
30645
  name: raw.includes("\\") ? null : raw
30630
30646
  };
@@ -30736,7 +30752,12 @@ function advancePastWrapper(words, start, spec) {
30736
30752
  * span (up to the `;`/`+` terminator).
30737
30753
  */
30738
30754
  function resolveFind(words, wrapperDepth) {
30739
- const executables = ["find"];
30755
+ const find = invocationFor(words);
30756
+ if (!find) return {
30757
+ ok: false,
30758
+ reason: "non-literal find invocation"
30759
+ };
30760
+ const invocations = [find];
30740
30761
  const escapes = [];
30741
30762
  for (let i = 1; i < words.length; i++) {
30742
30763
  if (!FIND_EXEC_FLAGS.has(words[i].raw)) continue;
@@ -30753,13 +30774,13 @@ function resolveFind(words, wrapperDepth) {
30753
30774
  };
30754
30775
  const inner = resolveWords(cmd, wrapperDepth);
30755
30776
  if (!inner.ok) return inner;
30756
- executables.push(...inner.executables);
30777
+ invocations.push(...inner.invocations);
30757
30778
  escapes.push(...inner.escapes);
30758
30779
  i = k - 1;
30759
30780
  }
30760
30781
  return {
30761
30782
  ok: true,
30762
- executables,
30783
+ invocations,
30763
30784
  escapes
30764
30785
  };
30765
30786
  }
@@ -30772,7 +30793,7 @@ function resolveWords(words, wrapperDepth = 0) {
30772
30793
  const head = words[0];
30773
30794
  if (!head) return {
30774
30795
  ok: true,
30775
- executables: [],
30796
+ invocations: [],
30776
30797
  escapes: []
30777
30798
  };
30778
30799
  if (head.name === null) return {
@@ -30780,11 +30801,20 @@ function resolveWords(words, wrapperDepth = 0) {
30780
30801
  reason: `non-literal command name: ${head.raw}`
30781
30802
  };
30782
30803
  const name = head.name;
30804
+ if (WHITESPACE_RE.test(name)) return {
30805
+ ok: false,
30806
+ reason: "literal command name contains whitespace"
30807
+ };
30783
30808
  if (GLOB_RE.test(name)) return {
30784
30809
  ok: false,
30785
- reason: `command name contains a glob: ${name}`
30810
+ reason: "command name contains a glob"
30786
30811
  };
30787
30812
  const exe = baseName(name);
30813
+ const invocation = invocationFor(words);
30814
+ if (!invocation) return {
30815
+ ok: false,
30816
+ reason: `non-literal command name: ${head.raw}`
30817
+ };
30788
30818
  if (exe === "eval") return {
30789
30819
  ok: false,
30790
30820
  reason: "eval executes a dynamically built command"
@@ -30797,26 +30827,27 @@ function resolveWords(words, wrapperDepth = 0) {
30797
30827
  reason: "wrapper nesting too deep"
30798
30828
  };
30799
30829
  const advance = advancePastWrapper(words, 1, spec);
30830
+ const wrapperEscapes = escapeFlagCommands(exe, words);
30800
30831
  if (advance.kind === "deny") return {
30801
30832
  ok: false,
30802
30833
  reason: advance.reason
30803
30834
  };
30804
30835
  if (advance.kind === "none") return {
30805
30836
  ok: true,
30806
- executables: [exe],
30807
- escapes: []
30837
+ invocations: [invocation],
30838
+ escapes: wrapperEscapes
30808
30839
  };
30809
30840
  const inner = resolveWords(words.slice(advance.index), wrapperDepth + 1);
30810
30841
  if (!inner.ok) return inner;
30811
30842
  return {
30812
30843
  ok: true,
30813
- executables: [exe, ...inner.executables],
30814
- escapes: inner.escapes
30844
+ invocations: [invocation, ...inner.invocations],
30845
+ escapes: [...wrapperEscapes, ...inner.escapes]
30815
30846
  };
30816
30847
  }
30817
30848
  return {
30818
30849
  ok: true,
30819
- executables: [exe],
30850
+ invocations: [invocation],
30820
30851
  escapes: escapeFlagCommands(exe, words)
30821
30852
  };
30822
30853
  }
@@ -30965,6 +30996,10 @@ var ShellCommandAnalyzer = class ShellCommandAnalyzer {
30965
30996
  try {
30966
30997
  const root = tree.rootNode;
30967
30998
  const ast = root.toString();
30999
+ const hasOutputRedirection = root.descendantsOfType("file_redirect").some((redirect) => {
31000
+ const text = redirect?.text.trimStart() ?? "";
31001
+ return /^(?:\d+)?(?:>|>>|>\||&>|&>>|<>)/u.test(text);
31002
+ });
30968
31003
  if (root.hasError) return {
30969
31004
  ok: false,
30970
31005
  command,
@@ -30995,12 +31030,16 @@ var ShellCommandAnalyzer = class ShellCommandAnalyzer {
30995
31030
  ast
30996
31031
  };
30997
31032
  const raw = node.text;
30998
- for (const name of resolution.executables) tools.push({
30999
- name,
31000
- risk: classifyRisk(name),
31001
- capabilities: gtfobinsFunctions(name),
31002
- raw
31003
- });
31033
+ for (const invocation of resolution.invocations) {
31034
+ const { name } = invocation;
31035
+ tools.push({
31036
+ name,
31037
+ argv: invocation.argv,
31038
+ risk: classifyRisk(name),
31039
+ capabilities: gtfobinsFunctions(name),
31040
+ raw
31041
+ });
31042
+ }
31004
31043
  for (const sub of resolution.escapes) {
31005
31044
  if (depth >= MAX_ESCAPE_DEPTH) return {
31006
31045
  ok: false,
@@ -31012,7 +31051,7 @@ var ShellCommandAnalyzer = class ShellCommandAnalyzer {
31012
31051
  if (!nested.ok) return {
31013
31052
  ok: false,
31014
31053
  command,
31015
- reason: `in escape flag (${sub}): ${nested.reason}`,
31054
+ reason: `unresolvable command in escape flag: ${nested.reason}`,
31016
31055
  ast
31017
31056
  };
31018
31057
  tools.push(...nested.tools);
@@ -31028,7 +31067,8 @@ var ShellCommandAnalyzer = class ShellCommandAnalyzer {
31028
31067
  ok: true,
31029
31068
  command,
31030
31069
  tools,
31031
- ast
31070
+ ast,
31071
+ hasOutputRedirection
31032
31072
  };
31033
31073
  } finally {
31034
31074
  tree.delete();
@@ -32389,26 +32429,72 @@ function createGondolinBashOps(vm, localCwd, guestWorkspace) {
32389
32429
  function decideToolCall(input) {
32390
32430
  if (input.enforcement === "off") return { allow: true };
32391
32431
  const resolved = resolveNames(input);
32392
- if (resolved.kind === "unresolvable") return fenced(input.enforcement, `shell command could not be statically authorized: ${resolved.reason}`, `unresolvable shell command (watch): ${resolved.reason}`);
32432
+ if (resolved.kind === "unresolvable") return fenced(input.enforcement, "shell command could not be statically authorized", "unresolvable shell command (watch)");
32393
32433
  const arbitraryCode = [...new Set(resolved.tools.filter((tool) => tool.risk === "arbitrary-code").map((tool) => tool.name))];
32394
32434
  if (arbitraryCode.length > 0) return fenced(input.enforcement, `arbitrary-code interpreter not authorizable by tool policy: ${arbitraryCode.join(", ")}`, `would block — arbitrary-code interpreter (watch): ${arbitraryCode.join(", ")}`, arbitraryCode);
32395
- const missing = [...new Set(resolved.tools.map((tool) => tool.name))].filter((name) => !input.allowedTools.has(name));
32396
- if (missing.length === 0) return { allow: true };
32397
- return fenced(input.enforcement, `not permitted by tool policy: ${missing.join(", ")}`, `would block (watch): ${missing.join(", ")}`, missing);
32435
+ if (input.toolName !== "bash") {
32436
+ const missing = resolved.tools.map((tool) => tool.name).filter((name) => !input.allowedTools.has(name));
32437
+ if (missing.length === 0) return { allow: true };
32438
+ return fenced(input.enforcement, `not permitted by tool policy: ${missing.join(", ")}`, `would block (watch): ${missing.join(", ")}`, missing);
32439
+ }
32440
+ const matchedShellCommands = [];
32441
+ const missingShellCommands = resolved.tools.filter((tool) => {
32442
+ if (input.allowedTools.has(tool.name)) return false;
32443
+ const matched = input.allowedShellCommands.find((rule) => matchesArgvPrefix(tool.argv, rule.argvPrefix));
32444
+ if (!matched) return true;
32445
+ matchedShellCommands.push(toMatchedShellCommand(tool.name, matched.argvPrefix));
32446
+ return false;
32447
+ }).map(toMissingShellCommand);
32448
+ if (missingShellCommands.length === 0 && resolved.hasOutputRedirection && matchedShellCommands.length > 0) return fenced(input.enforcement, "shell output redirection requires broad executable permission", "would block shell output redirection under scoped command policy", [...new Set(matchedShellCommands.map(({ executable }) => executable))], matchedShellCommands.map(({ executable, argvPrefixFingerprint, argvPrefixLength }) => ({
32449
+ executable,
32450
+ argvFingerprint: argvPrefixFingerprint,
32451
+ argvLength: argvPrefixLength,
32452
+ dynamicTokenCount: 0
32453
+ })));
32454
+ if (missingShellCommands.length === 0) return matchedShellCommands.length > 0 ? {
32455
+ allow: true,
32456
+ matchedShellCommands
32457
+ } : { allow: true };
32458
+ const missing = [...new Set(missingShellCommands.map(({ executable }) => executable))];
32459
+ return fenced(input.enforcement, `not permitted by tool policy: ${missing.join(", ")}`, `would block (watch): ${missing.join(", ")}`, missing, missingShellCommands);
32460
+ }
32461
+ function fingerprintArgv(argv) {
32462
+ return `sha256:${createHash$1("sha256").update(JSON.stringify(argv.map((token) => token === null ? { dynamic: true } : token))).digest("hex").slice(0, 16)}`;
32463
+ }
32464
+ function toMatchedShellCommand(executable, argvPrefix) {
32465
+ return {
32466
+ executable,
32467
+ argvPrefixFingerprint: fingerprintArgv(argvPrefix),
32468
+ argvPrefixLength: argvPrefix.length
32469
+ };
32470
+ }
32471
+ function toMissingShellCommand(tool) {
32472
+ return {
32473
+ executable: tool.name,
32474
+ argvFingerprint: fingerprintArgv(tool.argv),
32475
+ argvLength: tool.argv.length,
32476
+ dynamicTokenCount: tool.argv.filter((token) => token === null).length
32477
+ };
32478
+ }
32479
+ function matchesArgvPrefix(argv, prefix) {
32480
+ return argv.length >= prefix.length && prefix.every((token, index) => argv[index] !== null && argv[index] === token);
32398
32481
  }
32399
32482
  /**
32400
32483
  * Shared enforce/watch branch: block in `enforce`, audit-and-allow in `watch`.
32401
32484
  * `enforcement` is never `off` here (short-circuited by the caller).
32402
32485
  */
32403
- function fenced(enforcement, blockReason, auditReason, missing) {
32486
+ function fenced(enforcement, blockReason, auditReason, missing, missingShellCommands) {
32404
32487
  if (enforcement === "enforce") return {
32405
32488
  allow: false,
32406
- reason: blockReason
32489
+ reason: blockReason,
32490
+ ...missing ? { missing } : {},
32491
+ ...missingShellCommands?.length ? { missingShellCommands } : {}
32407
32492
  };
32408
- return missing ? {
32493
+ return {
32409
32494
  audit: auditReason,
32410
- missing
32411
- } : { audit: auditReason };
32495
+ ...missing ? { missing } : {},
32496
+ ...missingShellCommands?.length ? { missingShellCommands } : {}
32497
+ };
32412
32498
  }
32413
32499
  /**
32414
32500
  * The authorization targets for a tool call. For structured tools it is the tool
@@ -32421,6 +32507,7 @@ function resolveNames(input) {
32421
32507
  kind: "names",
32422
32508
  tools: [{
32423
32509
  name: input.toolName,
32510
+ argv: [input.toolName],
32424
32511
  risk: "unknown"
32425
32512
  }]
32426
32513
  };
@@ -32434,8 +32521,10 @@ function resolveNames(input) {
32434
32521
  kind: "names",
32435
32522
  tools: analysis.tools.map((tool) => ({
32436
32523
  name: tool.name,
32524
+ argv: tool.argv,
32437
32525
  risk: tool.risk
32438
- }))
32526
+ })),
32527
+ hasOutputRedirection: analysis.hasOutputRedirection === true
32439
32528
  } : {
32440
32529
  kind: "unresolvable",
32441
32530
  reason: analysis.reason
@@ -32463,14 +32552,20 @@ async function resolveSessionToolPolicy(input) {
32463
32552
  if (input.enforcement === "off") return {
32464
32553
  enforcement: "off",
32465
32554
  allowedTools: /* @__PURE__ */ new Set(),
32555
+ allowedShellCommands: [],
32466
32556
  degraded: false
32467
32557
  };
32468
32558
  const timeoutMs = input.timeoutMs ?? 5e3;
32469
32559
  try {
32470
32560
  const resolved = await withTimeout$1(input.agent.runtimeProfiles.allowedTools(input.profileId, { teamId: input.teamId }), timeoutMs);
32561
+ const shellCommands = resolved.allowedShellCommands ?? [];
32471
32562
  return {
32472
32563
  enforcement: resolved.enforcement,
32473
32564
  allowedTools: new Set(resolved.allowedTools),
32565
+ allowedShellCommands: shellCommands.map((rule) => {
32566
+ if (rule.argvPrefix.length < 2 || rule.argvPrefix.length > 8 || rule.argvPrefix.some((token) => !token)) throw new Error("runtime returned an invalid shell command rule");
32567
+ return { argvPrefix: rule.argvPrefix };
32568
+ }),
32474
32569
  degraded: false
32475
32570
  };
32476
32571
  } catch (err) {
@@ -32485,6 +32580,7 @@ async function resolveSessionToolPolicy(input) {
32485
32580
  return {
32486
32581
  enforcement: input.enforcement,
32487
32582
  allowedTools: /* @__PURE__ */ new Set(),
32583
+ allowedShellCommands: [],
32488
32584
  degraded: true
32489
32585
  };
32490
32586
  }
@@ -32526,6 +32622,7 @@ function decideForEvent(event, policy, analyze) {
32526
32622
  command,
32527
32623
  enforcement: policy.enforcement,
32528
32624
  allowedTools: policy.allowedTools,
32625
+ allowedShellCommands: policy.allowedShellCommands,
32529
32626
  analyze
32530
32627
  });
32531
32628
  }
@@ -32539,7 +32636,14 @@ function createToolPolicyExtension(deps) {
32539
32636
  if (deps.policy.enforcement === "off") return;
32540
32637
  pi.on("tool_call", (event) => {
32541
32638
  const decision = decideForEvent(event, deps.policy, (command) => deps.analyzer.analyze(command));
32542
- if ("allow" in decision && decision.allow) return;
32639
+ if ("allow" in decision && decision.allow) {
32640
+ if (decision.matchedShellCommands?.length) deps.logger.debug({
32641
+ toolName: event.toolName,
32642
+ toolCallId: event.toolCallId,
32643
+ matchedShellCommands: decision.matchedShellCommands
32644
+ }, "tool_policy.shell_command_allowed");
32645
+ return;
32646
+ }
32543
32647
  if ("audit" in decision) {
32544
32648
  deps.logger.info({
32545
32649
  toolName: event.toolName,
@@ -32553,7 +32657,9 @@ function createToolPolicyExtension(deps) {
32553
32657
  toolName: event.toolName,
32554
32658
  toolCallId: event.toolCallId,
32555
32659
  degraded: deps.policy.degraded,
32556
- reason: decision.reason
32660
+ reason: decision.reason,
32661
+ missingExecutables: decision.missing,
32662
+ missingShellCommands: decision.missingShellCommands
32557
32663
  }, "tool_policy.blocked");
32558
32664
  return {
32559
32665
  block: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/pi-runtime",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Composable MoltNet runtime kernel for Pi agents in Gondolin VMs",
@@ -27,8 +27,8 @@
27
27
  "@opentelemetry/api": "^1.9.0",
28
28
  "typebox": "^1.2.8",
29
29
  "@themoltnet/agent-runtime": "0.38.0",
30
- "@themoltnet/sdk": "0.128.0",
31
- "@themoltnet/shell-command-analyzer": "0.2.0"
30
+ "@themoltnet/shell-command-analyzer": "0.3.0",
31
+ "@themoltnet/sdk": "0.128.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@earendil-works/pi-ai": "0.74.0",
@@ -45,8 +45,8 @@
45
45
  "vite": "^8.0.0",
46
46
  "vite-plugin-dts": "^4.5.4",
47
47
  "vitest": "^3.0.0",
48
- "@moltnet/crypto-service": "0.1.0",
49
48
  "@moltnet/models": "0.1.0",
49
+ "@moltnet/crypto-service": "0.1.0",
50
50
  "@moltnet/tasks": "0.1.0"
51
51
  },
52
52
  "engines": {