fixo-cli 1.0.4 → 2.0.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.

Potentially problematic release.


This version of fixo-cli might be problematic. Click here for more details.

Files changed (199) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +18 -14
  3. package/dist/agent/agent-client.d.ts +28 -6
  4. package/dist/agent/agent-client.d.ts.map +1 -1
  5. package/dist/agent/agent-client.js +118 -39
  6. package/dist/agent/agent-client.js.map +1 -1
  7. package/dist/agent/agent-pool.d.ts +55 -6
  8. package/dist/agent/agent-pool.d.ts.map +1 -1
  9. package/dist/agent/agent-pool.js +120 -20
  10. package/dist/agent/agent-pool.js.map +1 -1
  11. package/dist/agent/auto-verifier.d.ts +55 -0
  12. package/dist/agent/auto-verifier.d.ts.map +1 -0
  13. package/dist/agent/auto-verifier.js +50 -0
  14. package/dist/agent/auto-verifier.js.map +1 -0
  15. package/dist/agent/command-parser.d.ts.map +1 -1
  16. package/dist/agent/command-parser.js +176 -0
  17. package/dist/agent/command-parser.js.map +1 -1
  18. package/dist/agent/context-builder.d.ts +24 -0
  19. package/dist/agent/context-builder.d.ts.map +1 -0
  20. package/dist/agent/context-builder.js +197 -0
  21. package/dist/agent/context-builder.js.map +1 -0
  22. package/dist/agent/conversation.d.ts +14 -1
  23. package/dist/agent/conversation.d.ts.map +1 -1
  24. package/dist/agent/conversation.js +53 -7
  25. package/dist/agent/conversation.js.map +1 -1
  26. package/dist/agent/mcp-bridge.js +1 -1
  27. package/dist/agent/mcp-bridge.js.map +1 -1
  28. package/dist/agent/orchestrator.d.ts +45 -0
  29. package/dist/agent/orchestrator.d.ts.map +1 -1
  30. package/dist/agent/orchestrator.js +140 -3
  31. package/dist/agent/orchestrator.js.map +1 -1
  32. package/dist/agent/parser-adapter.d.ts +17 -0
  33. package/dist/agent/parser-adapter.d.ts.map +1 -1
  34. package/dist/agent/parser-adapter.js +254 -2
  35. package/dist/agent/parser-adapter.js.map +1 -1
  36. package/dist/agent/predictive-gate.d.ts.map +1 -1
  37. package/dist/agent/predictive-gate.js +4 -1
  38. package/dist/agent/predictive-gate.js.map +1 -1
  39. package/dist/agent/providers-manager.d.ts +5 -0
  40. package/dist/agent/providers-manager.d.ts.map +1 -1
  41. package/dist/agent/providers-manager.js +119 -8
  42. package/dist/agent/providers-manager.js.map +1 -1
  43. package/dist/agent/repo-map.d.ts +18 -1
  44. package/dist/agent/repo-map.d.ts.map +1 -1
  45. package/dist/agent/repo-map.js +144 -54
  46. package/dist/agent/repo-map.js.map +1 -1
  47. package/dist/agent/retry.js +1 -2
  48. package/dist/agent/retry.js.map +1 -1
  49. package/dist/agent/single-agent.d.ts.map +1 -1
  50. package/dist/agent/single-agent.js +129 -22
  51. package/dist/agent/single-agent.js.map +1 -1
  52. package/dist/agent/skills.d.ts.map +1 -1
  53. package/dist/agent/skills.js +2 -1
  54. package/dist/agent/skills.js.map +1 -1
  55. package/dist/agent/subagent.js +2 -2
  56. package/dist/agent/subagent.js.map +1 -1
  57. package/dist/agent/task-router.d.ts +46 -0
  58. package/dist/agent/task-router.d.ts.map +1 -0
  59. package/dist/agent/task-router.js +352 -0
  60. package/dist/agent/task-router.js.map +1 -0
  61. package/dist/agent/telemetry.d.ts +29 -1
  62. package/dist/agent/telemetry.d.ts.map +1 -1
  63. package/dist/agent/telemetry.js +25 -10
  64. package/dist/agent/telemetry.js.map +1 -1
  65. package/dist/agent/tool-definitions.d.ts +3 -0
  66. package/dist/agent/tool-definitions.d.ts.map +1 -0
  67. package/dist/agent/tool-definitions.js +519 -0
  68. package/dist/agent/tool-definitions.js.map +1 -0
  69. package/dist/agent/tool-executor.d.ts +6 -1
  70. package/dist/agent/tool-executor.d.ts.map +1 -1
  71. package/dist/agent/tool-executor.js +99 -553
  72. package/dist/agent/tool-executor.js.map +1 -1
  73. package/dist/agent/tools/command-tools.d.ts +6 -0
  74. package/dist/agent/tools/command-tools.d.ts.map +1 -0
  75. package/dist/agent/tools/command-tools.js +104 -0
  76. package/dist/agent/tools/command-tools.js.map +1 -0
  77. package/dist/agent/tools/file-tools.d.ts +15 -0
  78. package/dist/agent/tools/file-tools.d.ts.map +1 -0
  79. package/dist/agent/tools/file-tools.js +551 -0
  80. package/dist/agent/tools/file-tools.js.map +1 -0
  81. package/dist/agent/tools/todo-tools.d.ts +3 -0
  82. package/dist/agent/tools/todo-tools.d.ts.map +1 -0
  83. package/dist/agent/tools/todo-tools.js +70 -0
  84. package/dist/agent/tools/todo-tools.js.map +1 -0
  85. package/dist/agent/web-impl.d.ts.map +1 -1
  86. package/dist/agent/web-impl.js +45 -0
  87. package/dist/agent/web-impl.js.map +1 -1
  88. package/dist/agent/worker-agent.d.ts +3 -1
  89. package/dist/agent/worker-agent.d.ts.map +1 -1
  90. package/dist/agent/worker-agent.js +51 -14
  91. package/dist/agent/worker-agent.js.map +1 -1
  92. package/dist/config.d.ts +242 -0
  93. package/dist/config.d.ts.map +1 -1
  94. package/dist/config.js +79 -0
  95. package/dist/config.js.map +1 -1
  96. package/dist/git/git-manager.d.ts +33 -2
  97. package/dist/git/git-manager.d.ts.map +1 -1
  98. package/dist/git/git-manager.js +111 -15
  99. package/dist/git/git-manager.js.map +1 -1
  100. package/dist/git/git-ops.d.ts.map +1 -1
  101. package/dist/git/git-ops.js +2 -1
  102. package/dist/git/git-ops.js.map +1 -1
  103. package/dist/index.js +85 -8
  104. package/dist/index.js.map +1 -1
  105. package/dist/lsp/lsp-manager.js +1 -1
  106. package/dist/lsp/lsp-manager.js.map +1 -1
  107. package/dist/model-outcomes.d.ts.map +1 -1
  108. package/dist/model-outcomes.js +2 -1
  109. package/dist/model-outcomes.js.map +1 -1
  110. package/dist/planner.d.ts +0 -9
  111. package/dist/planner.d.ts.map +1 -1
  112. package/dist/planner.js +0 -9
  113. package/dist/planner.js.map +1 -1
  114. package/dist/project-memory.d.ts +12 -1
  115. package/dist/project-memory.d.ts.map +1 -1
  116. package/dist/project-memory.js +8 -6
  117. package/dist/project-memory.js.map +1 -1
  118. package/dist/runtime/loop-mitigation.d.ts +78 -7
  119. package/dist/runtime/loop-mitigation.d.ts.map +1 -1
  120. package/dist/runtime/loop-mitigation.js +122 -9
  121. package/dist/runtime/loop-mitigation.js.map +1 -1
  122. package/dist/runtime/os-sandbox.d.ts +100 -0
  123. package/dist/runtime/os-sandbox.d.ts.map +1 -0
  124. package/dist/runtime/os-sandbox.js +246 -0
  125. package/dist/runtime/os-sandbox.js.map +1 -0
  126. package/dist/runtime/run-inventory.d.ts +17 -0
  127. package/dist/runtime/run-inventory.d.ts.map +1 -0
  128. package/dist/runtime/run-inventory.js +49 -0
  129. package/dist/runtime/run-inventory.js.map +1 -0
  130. package/dist/runtime/staging.d.ts.map +1 -1
  131. package/dist/runtime/staging.js +4 -1
  132. package/dist/runtime/staging.js.map +1 -1
  133. package/dist/runtime/task-session.d.ts +14 -0
  134. package/dist/runtime/task-session.d.ts.map +1 -1
  135. package/dist/runtime/task-session.js +26 -0
  136. package/dist/runtime/task-session.js.map +1 -1
  137. package/dist/setup-wizard.d.ts +11 -3
  138. package/dist/setup-wizard.d.ts.map +1 -1
  139. package/dist/setup-wizard.js +113 -15
  140. package/dist/setup-wizard.js.map +1 -1
  141. package/dist/types.d.ts +8 -0
  142. package/dist/types.d.ts.map +1 -1
  143. package/dist/ui/commands/context-commands.d.ts +7 -0
  144. package/dist/ui/commands/context-commands.d.ts.map +1 -0
  145. package/dist/ui/commands/context-commands.js +241 -0
  146. package/dist/ui/commands/context-commands.js.map +1 -0
  147. package/dist/ui/commands/index.d.ts +3 -0
  148. package/dist/ui/commands/index.d.ts.map +1 -0
  149. package/dist/ui/commands/index.js +46 -0
  150. package/dist/ui/commands/index.js.map +1 -0
  151. package/dist/ui/commands/info-commands.d.ts +15 -0
  152. package/dist/ui/commands/info-commands.d.ts.map +1 -0
  153. package/dist/ui/commands/info-commands.js +122 -0
  154. package/dist/ui/commands/info-commands.js.map +1 -0
  155. package/dist/ui/commands/model-commands.d.ts +5 -0
  156. package/dist/ui/commands/model-commands.d.ts.map +1 -0
  157. package/dist/ui/commands/model-commands.js +417 -0
  158. package/dist/ui/commands/model-commands.js.map +1 -0
  159. package/dist/ui/commands/session-commands.d.ts +5 -0
  160. package/dist/ui/commands/session-commands.d.ts.map +1 -0
  161. package/dist/ui/commands/session-commands.js +154 -0
  162. package/dist/ui/commands/session-commands.js.map +1 -0
  163. package/dist/ui/commands/task-commands.d.ts +8 -0
  164. package/dist/ui/commands/task-commands.d.ts.map +1 -0
  165. package/dist/ui/commands/task-commands.js +152 -0
  166. package/dist/ui/commands/task-commands.js.map +1 -0
  167. package/dist/ui/commands/types.d.ts +46 -0
  168. package/dist/ui/commands/types.d.ts.map +1 -0
  169. package/dist/ui/commands/types.js +2 -0
  170. package/dist/ui/commands/types.js.map +1 -0
  171. package/dist/ui/commands/workspace-commands.d.ts +8 -0
  172. package/dist/ui/commands/workspace-commands.d.ts.map +1 -0
  173. package/dist/ui/commands/workspace-commands.js +131 -0
  174. package/dist/ui/commands/workspace-commands.js.map +1 -0
  175. package/dist/ui/loading-animation.d.ts +24 -0
  176. package/dist/ui/loading-animation.d.ts.map +1 -0
  177. package/dist/ui/loading-animation.js +123 -0
  178. package/dist/ui/loading-animation.js.map +1 -0
  179. package/dist/ui/markdown-stream.js +2 -2
  180. package/dist/ui/markdown-stream.js.map +1 -1
  181. package/dist/ui/prompt.d.ts +7 -0
  182. package/dist/ui/prompt.d.ts.map +1 -1
  183. package/dist/ui/prompt.js +435 -1214
  184. package/dist/ui/prompt.js.map +1 -1
  185. package/dist/ui/render-primitives.d.ts +6 -0
  186. package/dist/ui/render-primitives.d.ts.map +1 -1
  187. package/dist/ui/render-primitives.js +30 -13
  188. package/dist/ui/render-primitives.js.map +1 -1
  189. package/dist/ui/render.d.ts.map +1 -1
  190. package/dist/ui/render.js +2 -0
  191. package/dist/ui/render.js.map +1 -1
  192. package/package.json +17 -3
  193. package/scripts/check-vendor-wasm.js +11 -0
  194. package/vendor/tree-sitter-go.wasm +0 -0
  195. package/vendor/tree-sitter-javascript.wasm +0 -0
  196. package/vendor/tree-sitter-python.wasm +0 -0
  197. package/vendor/tree-sitter-rust.wasm +0 -0
  198. package/vendor/tree-sitter-tsx.wasm +0 -0
  199. package/vendor/tree-sitter-typescript.wasm +0 -0
@@ -19,25 +19,96 @@
19
19
  *
20
20
  * Hard-abort is left untouched — the detector still raises
21
21
  * SemanticLoopAbortedError at its existing threshold.
22
+ *
23
+ * ── Phase 1b (Sliding Window) ──────────────────────────────────────
24
+ *
25
+ * The original tracker stored warn counts permanently for the lifetime
26
+ * of the session and never decayed them. Once a file accumulated 2
27
+ * warns it became "immortal" — every subsequent read was rejected and,
28
+ * because `task-session.canMutate()` requires a fresh read before any
29
+ * write, the file also became un-modifiable. This deadlock was
30
+ * observed in the June 22, 2026 log session on `style.css`,
31
+ * `portfolio.css`, and `script.js`.
32
+ *
33
+ * Phase 1b adds an optional sliding-window mode. When enabled, each
34
+ * warn carries the `currentTurn` it occurred on, and `isBlocked()`
35
+ * only considers warns within the last N turns. Default N is 10. The
36
+ * legacy session-lifetime behavior is preserved as the default
37
+ * (toggled by `preferences.agent.loopGuard.useSlidingWindow`) until
38
+ * Phase 7 flips the flag after soak.
22
39
  */
23
40
  /** Number of `warn` verdicts on the same target before reads are blocked. */
24
41
  export declare const REPEAT_WARN_BLOCK_THRESHOLD = 2;
42
+ /** Default sliding-window size in tool calls (used when sliding is enabled). */
43
+ export declare const DEFAULT_BLOCK_WINDOW_TURNS = 10;
25
44
  export declare function isReadTool(name: string): boolean;
45
+ /**
46
+ * Construction-time options for the tracker. All fields optional.
47
+ * When `useSlidingWindow` is false (default), behavior is identical
48
+ * to the pre-Phase-1b implementation: warns accumulate for the entire
49
+ * session, and the block flips on at the threshold and stays on.
50
+ */
51
+ export interface LoopMitigationOptions {
52
+ /** When true, warns decay outside `blockWindowTurns`. Default false. */
53
+ useSlidingWindow?: boolean;
54
+ /** Sliding-window size in tool calls. Default {@link DEFAULT_BLOCK_WINDOW_TURNS}. */
55
+ blockWindowTurns?: number;
56
+ }
26
57
  export declare class LoopMitigationTracker {
58
+ /** Legacy-mode running count of warns per target. */
27
59
  private readonly warnCounts;
60
+ /** Legacy-mode set of targets that have flipped to blocked. */
28
61
  private readonly blockedTargets;
62
+ /** Sliding-mode timestamps (tool-call counts) of warns per target. */
63
+ private readonly warnTimestamps;
64
+ private readonly useSlidingWindow;
65
+ private readonly blockWindowTurns;
66
+ /**
67
+ * Monotonic fallback counter used when sliding mode is enabled but
68
+ * the caller didn't pass a `currentTurn`. Ensures the sliding-window
69
+ * path stays consistent (both recordWarn and isBlocked operate on
70
+ * the same datastructure) even for legacy callers.
71
+ */
72
+ private fallbackTurn;
73
+ constructor(options?: LoopMitigationOptions);
29
74
  /**
30
75
  * Record a `warn` verdict for `target`. Returns true if this verdict
31
76
  * pushed `target` over the block threshold (caller may choose to
32
77
  * surface a one-time "now blocking" message).
78
+ *
79
+ * `currentTurn` is required when the tracker was constructed with
80
+ * `useSlidingWindow: true` — it's the tool-call count at the moment
81
+ * of the warn, used to compare against the sliding window. In
82
+ * legacy mode the parameter is accepted but ignored, preserving
83
+ * backward-compatible callers.
84
+ */
85
+ recordWarn(target: string, currentTurn?: number): boolean;
86
+ private recordWarnLegacy;
87
+ /**
88
+ * True if the target has been blocked from further reads.
89
+ *
90
+ * In sliding-window mode, `currentTurn` should be passed so the
91
+ * window can be evaluated against the present moment. Without it
92
+ * the method evaluates against the most recent warn timestamp,
93
+ * which is usually correct but may keep a target blocked for one
94
+ * extra turn after the window would have decayed.
95
+ */
96
+ isBlocked(target: string, currentTurn?: number): boolean;
97
+ /**
98
+ * Number of warns recorded for the target (for diagnostics).
99
+ * In sliding mode this returns warns within the current window
100
+ * rather than the cumulative session count.
101
+ */
102
+ warnsFor(target: string, currentTurn?: number): number;
103
+ /**
104
+ * Reset all tracker state. Used between sessions, in tests, and
105
+ * (Phase 1b) optionally between orchestrator subtasks so one stuck
106
+ * subtask cannot poison the rest of the run.
107
+ *
108
+ * Passing a target resets state for just that path; passing nothing
109
+ * clears everything.
33
110
  */
34
- recordWarn(target: string): boolean;
35
- /** True if the target has been blocked from further reads. */
36
- isBlocked(target: string): boolean;
37
- /** Number of warns recorded for the target (for diagnostics). */
38
- warnsFor(target: string): number;
39
- /** Reset all state (used between sessions or in tests). */
40
- reset(): void;
111
+ reset(target?: string): void;
41
112
  }
42
113
  /**
43
114
  * Build the synthetic tool-result that replaces a blocked read.
@@ -1 +1 @@
1
- {"version":3,"file":"loop-mitigation.d.ts","sourceRoot":"","sources":["../../src/runtime/loop-mitigation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,6EAA6E;AAC7E,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAS7C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED,qBAAa,qBAAqB;IAChC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6B;IACxD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAqB;IAEpD;;;;OAIG;IACH,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO;IAWnC,8DAA8D;IAC9D,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO;IAIlC,iEAAiE;IACjE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM;IAIhC,2DAA2D;IAC3D,KAAK,IAAI,IAAI;CAId;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAShF"}
1
+ {"version":3,"file":"loop-mitigation.d.ts","sourceRoot":"","sources":["../../src/runtime/loop-mitigation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,6EAA6E;AAC7E,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAE7C,gFAAgF;AAChF,eAAO,MAAM,0BAA0B,KAAK,CAAC;AAS7C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACpC,wEAAwE;IACxE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,qBAAa,qBAAqB;IAChC,qDAAqD;IACrD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6B;IACxD,+DAA+D;IAC/D,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAqB;IACpD,sEAAsE;IACtE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA+B;IAE9D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAU;IAC3C,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C;;;;;OAKG;IACH,OAAO,CAAC,YAAY,CAAK;gBAEb,OAAO,GAAE,qBAA0B;IAK/C;;;;;;;;;;OAUG;IACH,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO;IA0BzD,OAAO,CAAC,gBAAgB;IAWxB;;;;;;;;OAQG;IACH,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO;IAYxD;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM;IAWtD;;;;;;;OAOG;IACH,KAAK,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;CAY7B;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAShF"}
@@ -19,9 +19,28 @@
19
19
  *
20
20
  * Hard-abort is left untouched — the detector still raises
21
21
  * SemanticLoopAbortedError at its existing threshold.
22
+ *
23
+ * ── Phase 1b (Sliding Window) ──────────────────────────────────────
24
+ *
25
+ * The original tracker stored warn counts permanently for the lifetime
26
+ * of the session and never decayed them. Once a file accumulated 2
27
+ * warns it became "immortal" — every subsequent read was rejected and,
28
+ * because `task-session.canMutate()` requires a fresh read before any
29
+ * write, the file also became un-modifiable. This deadlock was
30
+ * observed in the June 22, 2026 log session on `style.css`,
31
+ * `portfolio.css`, and `script.js`.
32
+ *
33
+ * Phase 1b adds an optional sliding-window mode. When enabled, each
34
+ * warn carries the `currentTurn` it occurred on, and `isBlocked()`
35
+ * only considers warns within the last N turns. Default N is 10. The
36
+ * legacy session-lifetime behavior is preserved as the default
37
+ * (toggled by `preferences.agent.loopGuard.useSlidingWindow`) until
38
+ * Phase 7 flips the flag after soak.
22
39
  */
23
40
  /** Number of `warn` verdicts on the same target before reads are blocked. */
24
41
  export const REPEAT_WARN_BLOCK_THRESHOLD = 2;
42
+ /** Default sliding-window size in tool calls (used when sliding is enabled). */
43
+ export const DEFAULT_BLOCK_WINDOW_TURNS = 10;
25
44
  /** Tool names that count as "reading" the target path. */
26
45
  const READ_TOOL_NAMES = new Set([
27
46
  'read_file',
@@ -32,14 +51,63 @@ export function isReadTool(name) {
32
51
  return READ_TOOL_NAMES.has(name);
33
52
  }
34
53
  export class LoopMitigationTracker {
54
+ /** Legacy-mode running count of warns per target. */
35
55
  warnCounts = new Map();
56
+ /** Legacy-mode set of targets that have flipped to blocked. */
36
57
  blockedTargets = new Set();
58
+ /** Sliding-mode timestamps (tool-call counts) of warns per target. */
59
+ warnTimestamps = new Map();
60
+ useSlidingWindow;
61
+ blockWindowTurns;
62
+ /**
63
+ * Monotonic fallback counter used when sliding mode is enabled but
64
+ * the caller didn't pass a `currentTurn`. Ensures the sliding-window
65
+ * path stays consistent (both recordWarn and isBlocked operate on
66
+ * the same datastructure) even for legacy callers.
67
+ */
68
+ fallbackTurn = 0;
69
+ constructor(options = {}) {
70
+ this.useSlidingWindow = options.useSlidingWindow === true;
71
+ this.blockWindowTurns = options.blockWindowTurns ?? DEFAULT_BLOCK_WINDOW_TURNS;
72
+ }
37
73
  /**
38
74
  * Record a `warn` verdict for `target`. Returns true if this verdict
39
75
  * pushed `target` over the block threshold (caller may choose to
40
76
  * surface a one-time "now blocking" message).
77
+ *
78
+ * `currentTurn` is required when the tracker was constructed with
79
+ * `useSlidingWindow: true` — it's the tool-call count at the moment
80
+ * of the warn, used to compare against the sliding window. In
81
+ * legacy mode the parameter is accepted but ignored, preserving
82
+ * backward-compatible callers.
41
83
  */
42
- recordWarn(target) {
84
+ recordWarn(target, currentTurn) {
85
+ if (this.useSlidingWindow) {
86
+ // Sliding mode: append a timestamp and re-evaluate block state.
87
+ // If the caller didn't pass currentTurn, use a monotonic fallback
88
+ // so the sliding path remains the single source of truth (rather
89
+ // than splitting state across legacy + sliding stores).
90
+ const turn = currentTurn ?? ++this.fallbackTurn;
91
+ if (currentTurn !== undefined) {
92
+ // Keep fallback ahead of explicit turns so a later mixed call
93
+ // never re-uses an earlier turn id.
94
+ if (currentTurn > this.fallbackTurn)
95
+ this.fallbackTurn = currentTurn;
96
+ }
97
+ const arr = this.warnTimestamps.get(target) ?? [];
98
+ arr.push(turn);
99
+ // Prune anything outside the current window so the array doesn't
100
+ // grow unboundedly across long sessions.
101
+ const cutoff = turn - this.blockWindowTurns;
102
+ const pruned = arr.filter((t) => t >= cutoff);
103
+ this.warnTimestamps.set(target, pruned);
104
+ const wasBlocked = pruned.length - 1 >= REPEAT_WARN_BLOCK_THRESHOLD; // prior state (before this push, but pruned)
105
+ const isNowBlocked = pruned.length >= REPEAT_WARN_BLOCK_THRESHOLD;
106
+ return !wasBlocked && isNowBlocked;
107
+ }
108
+ return this.recordWarnLegacy(target);
109
+ }
110
+ recordWarnLegacy(target) {
43
111
  const prev = this.warnCounts.get(target) ?? 0;
44
112
  const next = prev + 1;
45
113
  this.warnCounts.set(target, next);
@@ -49,18 +117,63 @@ export class LoopMitigationTracker {
49
117
  }
50
118
  return false;
51
119
  }
52
- /** True if the target has been blocked from further reads. */
53
- isBlocked(target) {
120
+ /**
121
+ * True if the target has been blocked from further reads.
122
+ *
123
+ * In sliding-window mode, `currentTurn` should be passed so the
124
+ * window can be evaluated against the present moment. Without it
125
+ * the method evaluates against the most recent warn timestamp,
126
+ * which is usually correct but may keep a target blocked for one
127
+ * extra turn after the window would have decayed.
128
+ */
129
+ isBlocked(target, currentTurn) {
130
+ if (this.useSlidingWindow) {
131
+ const arr = this.warnTimestamps.get(target);
132
+ if (!arr || arr.length === 0)
133
+ return false;
134
+ const ref = currentTurn ?? arr[arr.length - 1];
135
+ const cutoff = ref - this.blockWindowTurns;
136
+ const live = arr.filter((t) => t >= cutoff);
137
+ return live.length >= REPEAT_WARN_BLOCK_THRESHOLD;
138
+ }
54
139
  return this.blockedTargets.has(target);
55
140
  }
56
- /** Number of warns recorded for the target (for diagnostics). */
57
- warnsFor(target) {
141
+ /**
142
+ * Number of warns recorded for the target (for diagnostics).
143
+ * In sliding mode this returns warns within the current window
144
+ * rather than the cumulative session count.
145
+ */
146
+ warnsFor(target, currentTurn) {
147
+ if (this.useSlidingWindow) {
148
+ const arr = this.warnTimestamps.get(target);
149
+ if (!arr)
150
+ return 0;
151
+ if (currentTurn === undefined)
152
+ return arr.length;
153
+ const cutoff = currentTurn - this.blockWindowTurns;
154
+ return arr.filter((t) => t >= cutoff).length;
155
+ }
58
156
  return this.warnCounts.get(target) ?? 0;
59
157
  }
60
- /** Reset all state (used between sessions or in tests). */
61
- reset() {
62
- this.warnCounts.clear();
63
- this.blockedTargets.clear();
158
+ /**
159
+ * Reset all tracker state. Used between sessions, in tests, and
160
+ * (Phase 1b) optionally between orchestrator subtasks so one stuck
161
+ * subtask cannot poison the rest of the run.
162
+ *
163
+ * Passing a target resets state for just that path; passing nothing
164
+ * clears everything.
165
+ */
166
+ reset(target) {
167
+ if (target === undefined) {
168
+ this.warnCounts.clear();
169
+ this.blockedTargets.clear();
170
+ this.warnTimestamps.clear();
171
+ this.fallbackTurn = 0;
172
+ return;
173
+ }
174
+ this.warnCounts.delete(target);
175
+ this.blockedTargets.delete(target);
176
+ this.warnTimestamps.delete(target);
64
177
  }
65
178
  }
66
179
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"loop-mitigation.js","sourceRoot":"","sources":["../../src/runtime/loop-mitigation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,6EAA6E;AAC7E,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,CAAC;AAE7C,0DAA0D;AAC1D,MAAM,eAAe,GAAwB,IAAI,GAAG,CAAC;IACnD,WAAW;IACX,iBAAiB;IACjB,iBAAiB;CAClB,CAAC,CAAC;AAEH,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,OAAO,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AACnC,CAAC;AAED,MAAM,OAAO,qBAAqB;IACf,UAAU,GAAG,IAAI,GAAG,EAAkB,CAAC;IACvC,cAAc,GAAG,IAAI,GAAG,EAAU,CAAC;IAEpD;;;;OAIG;IACH,UAAU,CAAC,MAAc;QACvB,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAC9C,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,CAAC;QACtB,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAClC,IAAI,IAAI,IAAI,2BAA2B,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAC5E,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;YAChC,OAAO,IAAI,CAAC;QACd,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,8DAA8D;IAC9D,SAAS,CAAC,MAAc;QACtB,OAAO,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACzC,CAAC;IAED,iEAAiE;IACjE,QAAQ,CAAC,MAAc;QACrB,OAAO,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC1C,CAAC;IAED,2DAA2D;IAC3D,KAAK;QACH,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;QACxB,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,CAAC;IAC9B,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,UAAU,0BAA0B,CAAC,MAAc,EAAE,KAAa;IACtE,OAAO,CACL,iEAAiE,MAAM,IAAI;QAC3E,QAAQ,KAAK,0DAA0D;QACvE,oEAAoE;QACpE,iEAAiE;QACjE,iFAAiF;QACjF,sFAAsF,CACvF,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"loop-mitigation.js","sourceRoot":"","sources":["../../src/runtime/loop-mitigation.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * os-sandbox.ts — Phase 1.2 OS-level sandbox wrapper for `run_command`.
3
+ *
4
+ * The CLI already enforces a regex-based command-parser layer and
5
+ * a WorkspaceGuard path-boundary check before any shell command runs.
6
+ * That stops the obvious cases (`> /etc/passwd`, `sed -i ../foo`) but
7
+ * is not a complete defence against a confused or prompt-injected
8
+ * model — a regex blocklist is a speed bump, not a wall.
9
+ *
10
+ * This module adds an OS-enforced second layer that the model has no
11
+ * way around: `sandbox-exec` on macOS, `bwrap` on Linux. Both use the
12
+ * same OS primitives Apple's own `xcrun` and Flatpak rely on, so the
13
+ * deny semantics are exactly the kernel's, not a JavaScript regex.
14
+ *
15
+ * Design choices, deliberate:
16
+ *
17
+ * - Default is OFF (config `safety.sandboxMode === 'guard'`). Turning
18
+ * it on changes runtime behaviour and we want users to opt in
19
+ * explicitly, not be surprised by a build that suddenly fails
20
+ * because their `npm test` script writes to `~/.npm/_cacache`.
21
+ *
22
+ * - When ON and the platform binary is missing, we surface a clear
23
+ * structured error instead of silently downgrading. A user who
24
+ * opted into `os-sandbox` and got the regex layer would have the
25
+ * worst of both worlds — false safety.
26
+ *
27
+ * - Network is allowed by default — most legitimate build/test
28
+ * commands need it (npm install, cargo fetch, pip). Callers that
29
+ * want strict isolation can pass `allowNetwork: false`.
30
+ *
31
+ * - Reads are unrestricted. The agent legitimately needs to inspect
32
+ * the system to plan its work. Writes are the dangerous side.
33
+ *
34
+ * - The macOS profile is generated at runtime into the OS temp dir
35
+ * from a template (no static `.sb` file shipped) so allow-write
36
+ * paths can be parameterised per-invocation without macros.
37
+ * `sandbox-exec` is technically deprecated by Apple but still
38
+ * ships and works on darwin 25; what's available today wins.
39
+ */
40
+ import { type SpawnSyncReturns } from 'node:child_process';
41
+ export type SandboxPlatform = 'darwin' | 'linux';
42
+ export interface SandboxOpts {
43
+ /** Directory the shell runs in. Must be readable. */
44
+ cwd: string;
45
+ /**
46
+ * Roots the sandboxed process may write to. Reads are not
47
+ * restricted — only writes. Always includes the OS temp dir
48
+ * regardless of what the caller passes, because nearly every
49
+ * build tool writes there.
50
+ */
51
+ allowedWritePaths: string[];
52
+ /**
53
+ * Whether the sandboxed process may open network sockets. Most
54
+ * legitimate `run_command` calls need this (npm install,
55
+ * cargo fetch), so the caller usually wants `true`.
56
+ */
57
+ allowNetwork: boolean;
58
+ /** Wall-clock timeout in ms. Defaults to 60s. */
59
+ timeout?: number;
60
+ /** Max captured stdout/stderr bytes. Defaults to 1 MiB. */
61
+ maxBuffer?: number;
62
+ /** Environment for the child process. */
63
+ env?: NodeJS.ProcessEnv;
64
+ }
65
+ /**
66
+ * Why a sandboxed `run_command` couldn't actually be wrapped in an
67
+ * OS sandbox. Distinct from a *command-failed-inside-the-sandbox*
68
+ * error, which surfaces through {@link SpawnSyncReturns.status} as
69
+ * usual.
70
+ */
71
+ export declare class SandboxUnavailableError extends Error {
72
+ platform: NodeJS.Platform;
73
+ reason: string;
74
+ constructor(platform: NodeJS.Platform, reason: string);
75
+ }
76
+ /**
77
+ * Run a shell command inside an OS-enforced sandbox.
78
+ *
79
+ * Throws {@link SandboxUnavailableError} if the current platform has
80
+ * no supported wrapper or the required binary is not on `$PATH`.
81
+ * Otherwise returns the same {@link SpawnSyncReturns} shape the
82
+ * caller would get from a plain `spawnSync(command, { shell: true })`
83
+ * call — so the call site only differs by routing decision, not by
84
+ * result handling.
85
+ */
86
+ export declare function runSandboxed(command: string, opts: SandboxOpts): SpawnSyncReturns<string>;
87
+ /**
88
+ * Best-effort probe: would {@link runSandboxed} succeed on this
89
+ * machine without actually running a command? Used by the CLI's
90
+ * startup sanity check so we can warn at boot rather than at the
91
+ * first command. Returns `null` on success, a structured reason on
92
+ * failure.
93
+ */
94
+ export declare function probeSandbox(): {
95
+ ok: true;
96
+ } | {
97
+ ok: false;
98
+ reason: string;
99
+ };
100
+ //# sourceMappingURL=os-sandbox.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"os-sandbox.d.ts","sourceRoot":"","sources":["../../src/runtime/os-sandbox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,EAAa,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAKtE,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEjD,MAAM,WAAW,WAAW;IAC1B,qDAAqD;IACrD,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;OAKG;IACH,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B;;;;OAIG;IACH,YAAY,EAAE,OAAO,CAAC;IACtB,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yCAAyC;IACzC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACzB;AAED;;;;;GAKG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;IAC7B,QAAQ,EAAE,MAAM,CAAC,QAAQ;IAAS,MAAM,EAAE,MAAM;gBAAhD,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAS,MAAM,EAAE,MAAM;CAIpE;AAID;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,gBAAgB,CAAC,MAAM,CAAC,CASzF;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAe3E"}
@@ -0,0 +1,246 @@
1
+ /**
2
+ * os-sandbox.ts — Phase 1.2 OS-level sandbox wrapper for `run_command`.
3
+ *
4
+ * The CLI already enforces a regex-based command-parser layer and
5
+ * a WorkspaceGuard path-boundary check before any shell command runs.
6
+ * That stops the obvious cases (`> /etc/passwd`, `sed -i ../foo`) but
7
+ * is not a complete defence against a confused or prompt-injected
8
+ * model — a regex blocklist is a speed bump, not a wall.
9
+ *
10
+ * This module adds an OS-enforced second layer that the model has no
11
+ * way around: `sandbox-exec` on macOS, `bwrap` on Linux. Both use the
12
+ * same OS primitives Apple's own `xcrun` and Flatpak rely on, so the
13
+ * deny semantics are exactly the kernel's, not a JavaScript regex.
14
+ *
15
+ * Design choices, deliberate:
16
+ *
17
+ * - Default is OFF (config `safety.sandboxMode === 'guard'`). Turning
18
+ * it on changes runtime behaviour and we want users to opt in
19
+ * explicitly, not be surprised by a build that suddenly fails
20
+ * because their `npm test` script writes to `~/.npm/_cacache`.
21
+ *
22
+ * - When ON and the platform binary is missing, we surface a clear
23
+ * structured error instead of silently downgrading. A user who
24
+ * opted into `os-sandbox` and got the regex layer would have the
25
+ * worst of both worlds — false safety.
26
+ *
27
+ * - Network is allowed by default — most legitimate build/test
28
+ * commands need it (npm install, cargo fetch, pip). Callers that
29
+ * want strict isolation can pass `allowNetwork: false`.
30
+ *
31
+ * - Reads are unrestricted. The agent legitimately needs to inspect
32
+ * the system to plan its work. Writes are the dangerous side.
33
+ *
34
+ * - The macOS profile is generated at runtime into the OS temp dir
35
+ * from a template (no static `.sb` file shipped) so allow-write
36
+ * paths can be parameterised per-invocation without macros.
37
+ * `sandbox-exec` is technically deprecated by Apple but still
38
+ * ships and works on darwin 25; what's available today wins.
39
+ */
40
+ import { spawnSync } from 'node:child_process';
41
+ import fs from 'node:fs';
42
+ import os from 'node:os';
43
+ import path from 'node:path';
44
+ /**
45
+ * Why a sandboxed `run_command` couldn't actually be wrapped in an
46
+ * OS sandbox. Distinct from a *command-failed-inside-the-sandbox*
47
+ * error, which surfaces through {@link SpawnSyncReturns.status} as
48
+ * usual.
49
+ */
50
+ export class SandboxUnavailableError extends Error {
51
+ platform;
52
+ reason;
53
+ constructor(platform, reason) {
54
+ super(`OS sandbox unavailable on ${platform}: ${reason}`);
55
+ this.platform = platform;
56
+ this.reason = reason;
57
+ this.name = 'SandboxUnavailableError';
58
+ }
59
+ }
60
+ /* ──────────────────────── Public entry point ──────────────────────── */
61
+ /**
62
+ * Run a shell command inside an OS-enforced sandbox.
63
+ *
64
+ * Throws {@link SandboxUnavailableError} if the current platform has
65
+ * no supported wrapper or the required binary is not on `$PATH`.
66
+ * Otherwise returns the same {@link SpawnSyncReturns} shape the
67
+ * caller would get from a plain `spawnSync(command, { shell: true })`
68
+ * call — so the call site only differs by routing decision, not by
69
+ * result handling.
70
+ */
71
+ export function runSandboxed(command, opts) {
72
+ const platform = process.platform;
73
+ if (platform === 'darwin') {
74
+ return runSandboxedMacos(command, opts);
75
+ }
76
+ if (platform === 'linux') {
77
+ return runSandboxedLinux(command, opts);
78
+ }
79
+ throw new SandboxUnavailableError(platform, 'no supported OS sandbox wrapper for this platform');
80
+ }
81
+ /**
82
+ * Best-effort probe: would {@link runSandboxed} succeed on this
83
+ * machine without actually running a command? Used by the CLI's
84
+ * startup sanity check so we can warn at boot rather than at the
85
+ * first command. Returns `null` on success, a structured reason on
86
+ * failure.
87
+ */
88
+ export function probeSandbox() {
89
+ const platform = process.platform;
90
+ if (platform === 'darwin') {
91
+ const which = whichBinary('sandbox-exec');
92
+ return which ? { ok: true } : { ok: false, reason: '`sandbox-exec` not on $PATH (macOS system tool)' };
93
+ }
94
+ if (platform === 'linux') {
95
+ const which = whichBinary('bwrap');
96
+ if (which)
97
+ return { ok: true };
98
+ return {
99
+ ok: false,
100
+ reason: '`bwrap` (bubblewrap) not installed. Install with: apt install bubblewrap | dnf install bubblewrap | apk add bubblewrap',
101
+ };
102
+ }
103
+ return { ok: false, reason: `unsupported platform: ${platform}` };
104
+ }
105
+ /* ──────────────────────── macOS: sandbox-exec ──────────────────────── */
106
+ function runSandboxedMacos(command, opts) {
107
+ if (!whichBinary('sandbox-exec')) {
108
+ throw new SandboxUnavailableError('darwin', '`sandbox-exec` not on $PATH');
109
+ }
110
+ const profile = buildMacosProfile({
111
+ allowedWritePaths: dedupeAndResolve([...opts.allowedWritePaths, os.tmpdir()]),
112
+ allowNetwork: opts.allowNetwork,
113
+ });
114
+ // Write the profile to a temp file. sandbox-exec will read it
115
+ // before exec; we delete it immediately after the child returns.
116
+ const profilePath = path.join(os.tmpdir(), `fixo-sandbox-${process.pid}-${Date.now()}.sb`);
117
+ fs.writeFileSync(profilePath, profile, { encoding: 'utf-8', mode: 0o600 });
118
+ try {
119
+ return spawnSync('sandbox-exec', ['-f', profilePath, '/bin/sh', '-c', command], {
120
+ cwd: opts.cwd,
121
+ encoding: 'utf-8',
122
+ timeout: opts.timeout ?? 60_000,
123
+ maxBuffer: opts.maxBuffer ?? 1024 * 1024,
124
+ env: opts.env ?? process.env,
125
+ });
126
+ }
127
+ finally {
128
+ try {
129
+ fs.unlinkSync(profilePath);
130
+ }
131
+ catch { /* safe: best-effort cleanup of tmp profile */ }
132
+ }
133
+ }
134
+ function buildMacosProfile(opts) {
135
+ // Quote each allow-write path in TinyScheme-style string literal
136
+ // form. sandbox-exec uses backslash-escaped double-quoted strings.
137
+ const writeRoots = opts.allowedWritePaths
138
+ .map((p) => ` (subpath ${quoteSb(p)})`)
139
+ .join('\n');
140
+ const networkRule = opts.allowNetwork ? '(allow network*)' : '(deny network*)';
141
+ // Inspired by Apple's own /System/Library/Sandbox/Profiles seed
142
+ // profiles. `allow file-read*` is total because the agent must be
143
+ // able to read the system to do useful work; writes are the side
144
+ // we lock down. The auxiliary `allow` rules (sysctl, mach-lookup,
145
+ // process-fork, ipc-posix-shm) are the bare minimum a `/bin/sh`
146
+ // exec of a typical build command needs to not crash.
147
+ return `(version 1)
148
+ (deny default)
149
+
150
+ ; Reads: unrestricted. The agent needs to inspect the system.
151
+ (allow file-read*)
152
+
153
+ ; Writes: only inside the explicitly-allowed roots.
154
+ (allow file-write*
155
+ ${writeRoots})
156
+
157
+ ; Process / sysctl / IPC: required for /bin/sh to execute a command.
158
+ (allow process-fork)
159
+ (allow process-exec*)
160
+ (allow sysctl-read)
161
+ (allow mach-lookup)
162
+ (allow file-ioctl)
163
+ (allow ipc-posix-shm)
164
+ (allow signal (target self))
165
+
166
+ ; Network rule (gated by caller).
167
+ ${networkRule}
168
+ `;
169
+ }
170
+ function quoteSb(s) {
171
+ // sandbox-exec strings use double-quotes with backslash escaping.
172
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
173
+ }
174
+ /* ──────────────────────── Linux: bubblewrap ──────────────────────── */
175
+ function runSandboxedLinux(command, opts) {
176
+ if (!whichBinary('bwrap')) {
177
+ throw new SandboxUnavailableError('linux', '`bwrap` (bubblewrap) not installed. Install with: apt install bubblewrap | dnf install bubblewrap | apk add bubblewrap');
178
+ }
179
+ const writeRoots = dedupeAndResolve([...opts.allowedWritePaths, os.tmpdir()]);
180
+ const args = [
181
+ // Read-only mount the standard system tree. /usr is the
182
+ // important one — that's where /bin/sh, coreutils, etc. live.
183
+ '--ro-bind', '/usr', '/usr',
184
+ '--ro-bind', '/bin', '/bin',
185
+ '--ro-bind', '/sbin', '/sbin',
186
+ '--ro-bind', '/lib', '/lib',
187
+ '--symlink', 'usr/lib64', '/lib64',
188
+ '--ro-bind', '/etc', '/etc',
189
+ // Procfs + devtmpfs: needed by virtually every binary.
190
+ '--proc', '/proc',
191
+ '--dev', '/dev',
192
+ // Tmpfs for /run and /var so the child can drop pidfiles.
193
+ '--tmpfs', '/run',
194
+ '--tmpfs', '/var',
195
+ ];
196
+ for (const wr of writeRoots) {
197
+ args.push('--bind', wr, wr);
198
+ }
199
+ if (!opts.allowNetwork) {
200
+ args.push('--unshare-net');
201
+ }
202
+ // PID + user namespaces — cheap isolation that doesn't break
203
+ // most build tools.
204
+ args.push('--unshare-pid');
205
+ args.push('--die-with-parent');
206
+ args.push('--chdir', opts.cwd);
207
+ args.push('/bin/sh', '-c', command);
208
+ return spawnSync('bwrap', args, {
209
+ cwd: opts.cwd,
210
+ encoding: 'utf-8',
211
+ timeout: opts.timeout ?? 60_000,
212
+ maxBuffer: opts.maxBuffer ?? 1024 * 1024,
213
+ env: opts.env ?? process.env,
214
+ });
215
+ }
216
+ /* ──────────────────────── Helpers ──────────────────────── */
217
+ function whichBinary(name) {
218
+ const which = spawnSync('which', [name], { encoding: 'utf-8' });
219
+ return which.status === 0 && which.stdout.trim().length > 0;
220
+ }
221
+ function dedupeAndResolve(paths) {
222
+ const seen = new Set();
223
+ const out = [];
224
+ for (const p of paths) {
225
+ // Canonicalize: on macOS `/var/folders/...` is actually
226
+ // `/private/var/folders/...`; sandbox-exec matches on the
227
+ // canonical path, so an allow rule written against the symlink
228
+ // path silently denies. Same trap exists for `/tmp` →
229
+ // `/private/tmp`. realpathSync resolves both. Fall back to the
230
+ // resolved-but-uncanonicalized path if the directory does not
231
+ // exist yet (callers may pass it before creation).
232
+ let abs;
233
+ try {
234
+ abs = fs.realpathSync(path.resolve(p));
235
+ }
236
+ catch {
237
+ abs = path.resolve(p);
238
+ }
239
+ if (seen.has(abs))
240
+ continue;
241
+ seen.add(abs);
242
+ out.push(abs);
243
+ }
244
+ return out;
245
+ }
246
+ //# sourceMappingURL=os-sandbox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"os-sandbox.js","sourceRoot":"","sources":["../../src/runtime/os-sandbox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,EAAE,SAAS,EAAyB,MAAM,oBAAoB,CAAC;AACtE,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AA4B7B;;;;;GAKG;AACH,MAAM,OAAO,uBAAwB,SAAQ,KAAK;IAC7B;IAAkC;IAArD,YAAmB,QAAyB,EAAS,MAAc;QACjE,KAAK,CAAC,6BAA6B,QAAQ,KAAK,MAAM,EAAE,CAAC,CAAC;QADzC,aAAQ,GAAR,QAAQ,CAAiB;QAAS,WAAM,GAAN,MAAM,CAAQ;QAEjE,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;IACxC,CAAC;CACF;AAED,0EAA0E;AAE1E;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY,CAAC,OAAe,EAAE,IAAiB;IAC7D,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;IAClC,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAC1B,OAAO,iBAAiB,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IAC1C,CAAC;IACD,IAAI,QAAQ,KAAK,OAAO,EAAE,CAAC;QACzB,OAAO,iBAAiB,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IAC1C,CAAC;IACD,MAAM,IAAI,uBAAuB,CAAC,QAAQ,EAAE,mDAAmD,CAAC,CAAC;AACnG,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY;IAC1B,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;IAClC,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAC1B,MAAM,KAAK,GAAG,WAAW,CAAC,cAAc,CAAC,CAAC;QAC1C,OAAO,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,iDAAiD,EAAE,CAAC;IACzG,CAAC;IACD,IAAI,QAAQ,KAAK,OAAO,EAAE,CAAC;QACzB,MAAM,KAAK,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC;QACnC,IAAI,KAAK;YAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;QAC/B,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,wHAAwH;SACjI,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,yBAAyB,QAAQ,EAAE,EAAE,CAAC;AACpE,CAAC;AAED,2EAA2E;AAE3E,SAAS,iBAAiB,CAAC,OAAe,EAAE,IAAiB;IAC3D,IAAI,CAAC,WAAW,CAAC,cAAc,CAAC,EAAE,CAAC;QACjC,MAAM,IAAI,uBAAuB,CAAC,QAAQ,EAAE,6BAA6B,CAAC,CAAC;IAC7E,CAAC;IAED,MAAM,OAAO,GAAG,iBAAiB,CAAC;QAChC,iBAAiB,EAAE,gBAAgB,CAAC,CAAC,GAAG,IAAI,CAAC,iBAAiB,EAAE,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC;QAC7E,YAAY,EAAE,IAAI,CAAC,YAAY;KAChC,CAAC,CAAC;IAEH,8DAA8D;IAC9D,iEAAiE;IACjE,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE,gBAAgB,OAAO,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC3F,EAAE,CAAC,aAAa,CAAC,WAAW,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAE3E,IAAI,CAAC;QACH,OAAO,SAAS,CAAC,cAAc,EAAE,CAAC,IAAI,EAAE,WAAW,EAAE,SAAS,EAAE,IAAI,EAAE,OAAO,CAAC,EAAE;YAC9E,GAAG,EAAE,IAAI,CAAC,GAAG;YACb,QAAQ,EAAE,OAAO;YACjB,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,MAAM;YAC/B,SAAS,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI,GAAG,IAAI;YACxC,GAAG,EAAE,IAAI,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG;SAC7B,CAAC,CAAC;IACL,CAAC;YAAS,CAAC;QACT,IAAI,CAAC;YAAC,EAAE,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,8CAA8C,CAAC,CAAC;IAC9F,CAAC;AACH,CAAC;AAED,SAAS,iBAAiB,CAAC,IAA4D;IACrF,iEAAiE;IACjE,mEAAmE;IACnE,MAAM,UAAU,GAAG,IAAI,CAAC,iBAAiB;SACtC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,cAAc,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC;SACvC,IAAI,CAAC,IAAI,CAAC,CAAC;IAEd,MAAM,WAAW,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,iBAAiB,CAAC;IAE/E,gEAAgE;IAChE,kEAAkE;IAClE,iEAAiE;IACjE,kEAAkE;IAClE,gEAAgE;IAChE,sDAAsD;IACtD,OAAO;;;;;;;;EAQP,UAAU;;;;;;;;;;;;EAYV,WAAW;CACZ,CAAC;AACF,CAAC;AAED,SAAS,OAAO,CAAC,CAAS;IACxB,kEAAkE;IAClE,OAAO,IAAI,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC;AAC9D,CAAC;AAED,yEAAyE;AAEzE,SAAS,iBAAiB,CAAC,OAAe,EAAE,IAAiB;IAC3D,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,uBAAuB,CAC/B,OAAO,EACP,wHAAwH,CACzH,CAAC;IACJ,CAAC;IAED,MAAM,UAAU,GAAG,gBAAgB,CAAC,CAAC,GAAG,IAAI,CAAC,iBAAiB,EAAE,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAE9E,MAAM,IAAI,GAAa;QACrB,wDAAwD;QACxD,8DAA8D;QAC9D,WAAW,EAAE,MAAM,EAAE,MAAM;QAC3B,WAAW,EAAE,MAAM,EAAE,MAAM;QAC3B,WAAW,EAAE,OAAO,EAAE,OAAO;QAC7B,WAAW,EAAE,MAAM,EAAE,MAAM;QAC3B,WAAW,EAAE,WAAW,EAAE,QAAQ;QAClC,WAAW,EAAE,MAAM,EAAE,MAAM;QAC3B,uDAAuD;QACvD,QAAQ,EAAE,OAAO;QACjB,OAAO,EAAE,MAAM;QACf,0DAA0D;QAC1D,SAAS,EAAE,MAAM;QACjB,SAAS,EAAE,MAAM;KAClB,CAAC;IAEF,KAAK,MAAM,EAAE,IAAI,UAAU,EAAE,CAAC;QAC5B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;IAC9B,CAAC;IAED,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC;QACvB,IAAI,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;IAC7B,CAAC;IAED,6DAA6D;IAC7D,oBAAoB;IACpB,IAAI,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;IAC3B,IAAI,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC;IAC/B,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;IAEpC,OAAO,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE;QAC9B,GAAG,EAAE,IAAI,CAAC,GAAG;QACb,QAAQ,EAAE,OAAO;QACjB,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,MAAM;QAC/B,SAAS,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI,GAAG,IAAI;QACxC,GAAG,EAAE,IAAI,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG;KAC7B,CAAC,CAAC;AACL,CAAC;AAED,+DAA+D;AAE/D,SAAS,WAAW,CAAC,IAAY;IAC/B,MAAM,KAAK,GAAG,SAAS,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,CAAC;IAChE,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AAC9D,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAe;IACvC,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;QACtB,wDAAwD;QACxD,0DAA0D;QAC1D,+DAA+D;QAC/D,sDAAsD;QACtD,+DAA+D;QAC/D,8DAA8D;QAC9D,mDAAmD;QACnD,IAAI,GAAW,CAAC;QAChB,IAAI,CAAC;YACH,GAAG,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;QACzC,CAAC;QAAC,MAAM,CAAC;YACP,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;QACxB,CAAC;QACD,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,SAAS;QAC5B,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACd,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAChB,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}