open-multi-agent-kit 0.96.2 → 0.97.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.
Files changed (138) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +4 -2
  3. package/dist/core/agent-session.d.ts +9 -1
  4. package/dist/core/agent-session.d.ts.map +1 -1
  5. package/dist/core/agent-session.js +84 -7
  6. package/dist/core/agent-session.js.map +1 -1
  7. package/dist/core/attachment-store.d.ts +39 -0
  8. package/dist/core/attachment-store.d.ts.map +1 -0
  9. package/dist/core/attachment-store.js +81 -0
  10. package/dist/core/attachment-store.js.map +1 -0
  11. package/dist/core/command-safety.d.ts +8 -0
  12. package/dist/core/command-safety.d.ts.map +1 -1
  13. package/dist/core/command-safety.js +140 -14
  14. package/dist/core/command-safety.js.map +1 -1
  15. package/dist/core/compaction/compaction.d.ts +6 -1
  16. package/dist/core/compaction/compaction.d.ts.map +1 -1
  17. package/dist/core/compaction/compaction.js +35 -1
  18. package/dist/core/compaction/compaction.js.map +1 -1
  19. package/dist/core/context-budget-governor-v2.d.ts +1 -1
  20. package/dist/core/context-budget-governor-v2.d.ts.map +1 -1
  21. package/dist/core/context-budget-governor-v2.js +1 -1
  22. package/dist/core/context-budget-governor-v2.js.map +1 -1
  23. package/dist/core/context-budget-v2-cache-keys.d.ts +3 -0
  24. package/dist/core/context-budget-v2-cache-keys.d.ts.map +1 -1
  25. package/dist/core/context-budget-v2-cache-keys.js +2 -0
  26. package/dist/core/context-budget-v2-cache-keys.js.map +1 -1
  27. package/dist/core/context-budget-v2-plan-cache-keys.d.ts.map +1 -1
  28. package/dist/core/context-budget-v2-plan-cache-keys.js +3 -0
  29. package/dist/core/context-budget-v2-plan-cache-keys.js.map +1 -1
  30. package/dist/core/context-budget-v2-planner.d.ts.map +1 -1
  31. package/dist/core/context-budget-v2-planner.js +2 -1
  32. package/dist/core/context-budget-v2-planner.js.map +1 -1
  33. package/dist/core/context-budget-v2-scoring.d.ts +17 -0
  34. package/dist/core/context-budget-v2-scoring.d.ts.map +1 -1
  35. package/dist/core/context-budget-v2-scoring.js +25 -8
  36. package/dist/core/context-budget-v2-scoring.js.map +1 -1
  37. package/dist/core/context-budget-v2-selection.d.ts +2 -2
  38. package/dist/core/context-budget-v2-selection.d.ts.map +1 -1
  39. package/dist/core/context-budget-v2-selection.js +18 -1
  40. package/dist/core/context-budget-v2-selection.js.map +1 -1
  41. package/dist/core/context-budget-v2-types.d.ts +17 -0
  42. package/dist/core/context-budget-v2-types.d.ts.map +1 -1
  43. package/dist/core/context-budget-v2-types.js +15 -0
  44. package/dist/core/context-budget-v2-types.js.map +1 -1
  45. package/dist/core/extensions/builtin/command-safety-gate.d.ts +3 -1
  46. package/dist/core/extensions/builtin/command-safety-gate.d.ts.map +1 -1
  47. package/dist/core/extensions/builtin/command-safety-gate.js +7 -0
  48. package/dist/core/extensions/builtin/command-safety-gate.js.map +1 -1
  49. package/dist/core/extensions/types.d.ts +12 -1
  50. package/dist/core/extensions/types.d.ts.map +1 -1
  51. package/dist/core/extensions/types.js.map +1 -1
  52. package/dist/core/model-registry.d.ts +8 -3
  53. package/dist/core/model-registry.d.ts.map +1 -1
  54. package/dist/core/model-registry.js +71 -1
  55. package/dist/core/model-registry.js.map +1 -1
  56. package/dist/core/package-doctor-source-scan.d.ts +9 -0
  57. package/dist/core/package-doctor-source-scan.d.ts.map +1 -1
  58. package/dist/core/package-doctor-source-scan.js +77 -4
  59. package/dist/core/package-doctor-source-scan.js.map +1 -1
  60. package/dist/core/prompt-attachment.d.ts +51 -0
  61. package/dist/core/prompt-attachment.d.ts.map +1 -0
  62. package/dist/core/prompt-attachment.js +186 -0
  63. package/dist/core/prompt-attachment.js.map +1 -0
  64. package/dist/core/provider-resilience.d.ts +28 -0
  65. package/dist/core/provider-resilience.d.ts.map +1 -1
  66. package/dist/core/provider-resilience.js +50 -0
  67. package/dist/core/provider-resilience.js.map +1 -1
  68. package/dist/core/provider-retry.d.ts +1 -1
  69. package/dist/core/provider-retry.d.ts.map +1 -1
  70. package/dist/core/provider-retry.js +1 -1
  71. package/dist/core/provider-retry.js.map +1 -1
  72. package/dist/core/provider-usage.d.ts.map +1 -1
  73. package/dist/core/provider-usage.js +68 -2
  74. package/dist/core/provider-usage.js.map +1 -1
  75. package/dist/core/reasoning-router-promotion.d.ts +84 -0
  76. package/dist/core/reasoning-router-promotion.d.ts.map +1 -0
  77. package/dist/core/reasoning-router-promotion.js +92 -0
  78. package/dist/core/reasoning-router-promotion.js.map +1 -0
  79. package/dist/core/session-bash-service.d.ts.map +1 -1
  80. package/dist/core/session-bash-service.js +4 -3
  81. package/dist/core/session-bash-service.js.map +1 -1
  82. package/dist/core/session-termination.d.ts +1 -1
  83. package/dist/core/session-termination.d.ts.map +1 -1
  84. package/dist/core/session-termination.js +11 -2
  85. package/dist/core/session-termination.js.map +1 -1
  86. package/dist/core/tools/bash.d.ts.map +1 -1
  87. package/dist/core/tools/bash.js +3 -7
  88. package/dist/core/tools/bash.js.map +1 -1
  89. package/dist/core/tools/grep.d.ts.map +1 -1
  90. package/dist/core/tools/grep.js +13 -48
  91. package/dist/core/tools/grep.js.map +1 -1
  92. package/dist/core/tools/read.d.ts.map +1 -1
  93. package/dist/core/tools/read.js +1 -34
  94. package/dist/core/tools/read.js.map +1 -1
  95. package/dist/index.d.ts +1 -1
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +1 -1
  98. package/dist/index.js.map +1 -1
  99. package/dist/modes/interactive/components/attachment-strip.d.ts +21 -0
  100. package/dist/modes/interactive/components/attachment-strip.d.ts.map +1 -0
  101. package/dist/modes/interactive/components/attachment-strip.js +136 -0
  102. package/dist/modes/interactive/components/attachment-strip.js.map +1 -0
  103. package/dist/modes/interactive/components/footer.d.ts +6 -0
  104. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  105. package/dist/modes/interactive/components/footer.js +26 -1
  106. package/dist/modes/interactive/components/footer.js.map +1 -1
  107. package/dist/modes/interactive/components/status-sidebar.d.ts.map +1 -1
  108. package/dist/modes/interactive/components/status-sidebar.js +12 -1
  109. package/dist/modes/interactive/components/status-sidebar.js.map +1 -1
  110. package/dist/modes/interactive/interactive-mode.d.ts +23 -4
  111. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  112. package/dist/modes/interactive/interactive-mode.js +174 -53
  113. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  114. package/docs/compaction.md +1 -1
  115. package/docs/extensions.md +12 -3
  116. package/docs/index.md +4 -1
  117. package/docs/metrics.md +74 -2
  118. package/docs/provider-resilience.md +11 -0
  119. package/docs/usage.md +1 -1
  120. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  121. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  122. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  123. package/examples/extensions/gondolin/package-lock.json +2 -2
  124. package/examples/extensions/gondolin/package.json +1 -1
  125. package/examples/extensions/sandbox/package-lock.json +2 -2
  126. package/examples/extensions/sandbox/package.json +1 -1
  127. package/examples/extensions/with-deps/package-lock.json +2 -2
  128. package/examples/extensions/with-deps/package.json +1 -1
  129. package/npm-shrinkwrap.json +18 -18
  130. package/package.json +7 -7
  131. package/dist/core/tools/omp-pure-seams.d.ts +0 -41
  132. package/dist/core/tools/omp-pure-seams.d.ts.map +0 -1
  133. package/dist/core/tools/omp-pure-seams.js +0 -142
  134. package/dist/core/tools/omp-pure-seams.js.map +0 -1
  135. package/dist/core/tools/omp-seam-runtime.d.ts +0 -100
  136. package/dist/core/tools/omp-seam-runtime.d.ts.map +0 -1
  137. package/dist/core/tools/omp-seam-runtime.js +0 -40
  138. package/dist/core/tools/omp-seam-runtime.js.map +0 -1
@@ -0,0 +1,39 @@
1
+ /**
2
+ * In-memory content-addressed store for prompt image attachments.
3
+ *
4
+ * Owns the raw bytes so the TUI draft survives editor swaps (extension editor,
5
+ * model selector) and only releases entries after the prompt is accepted.
6
+ * Raw base64 never enters logs or journals — materialization happens at the
7
+ * last step, right before `session.prompt(text, { images })`.
8
+ */
9
+ import type { ImageContent } from "omk-ai";
10
+ import { type PromptAttachmentSource, type PromptImageAttachment } from "./prompt-attachment.ts";
11
+ export interface AttachmentStoreLimits {
12
+ maxAttachments?: number;
13
+ maxImageBytes?: number;
14
+ maxPixels?: number;
15
+ maxDraftBytes?: number;
16
+ }
17
+ export declare class AttachmentStore {
18
+ private readonly entries;
19
+ private readonly maxAttachments;
20
+ private readonly maxImageBytes;
21
+ private readonly maxPixels;
22
+ private readonly maxDraftBytes;
23
+ constructor(limits?: AttachmentStoreLimits);
24
+ /** Validate + store bytes; returns the attachment record. Throws PromptAttachmentError. */
25
+ put(bytes: Uint8Array, source: PromptAttachmentSource): PromptImageAttachment;
26
+ get(id: string): PromptImageAttachment | undefined;
27
+ getBytes(id: string): Uint8Array | undefined;
28
+ remove(id: string): boolean;
29
+ clear(): void;
30
+ list(): PromptImageAttachment[];
31
+ totalBytes(): number;
32
+ /**
33
+ * Materialize attachments into `ImageContent[]` for
34
+ * `session.prompt(text, { images })`. Missing ids throw — callers snapshot
35
+ * ids at submit time and must not send half-removed drafts.
36
+ */
37
+ materializeImages(ids: readonly string[]): ImageContent[];
38
+ }
39
+ //# sourceMappingURL=attachment-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-store.d.ts","sourceRoot":"","sources":["../../src/core/attachment-store.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAC3C,OAAO,EAIN,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,MAAM,wBAAwB,CAAC;AAOhC,MAAM,WAAW,qBAAqB;IACrC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qBAAa,eAAe;IAC3B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuC;IAC/D,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IAEvC,YAAY,MAAM,GAAE,qBAA0B,EAK7C;IAED,2FAA2F;IAC3F,GAAG,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,sBAAsB,GAAG,qBAAqB,CAoB5E;IAED,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,qBAAqB,GAAG,SAAS,CAEjD;IAED,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAE3C;IAED,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAE1B;IAED,KAAK,IAAI,IAAI,CAEZ;IAED,IAAI,IAAI,qBAAqB,EAAE,CAE9B;IAED,UAAU,IAAI,MAAM,CAInB;IAED;;;;OAIG;IACH,iBAAiB,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,GAAG,YAAY,EAAE,CAUxD;CACD","sourcesContent":["/**\n * In-memory content-addressed store for prompt image attachments.\n *\n * Owns the raw bytes so the TUI draft survives editor swaps (extension editor,\n * model selector) and only releases entries after the prompt is accepted.\n * Raw base64 never enters logs or journals — materialization happens at the\n * last step, right before `session.prompt(text, { images })`.\n */\n\nimport type { ImageContent } from \"omk-ai\";\nimport {\n\tcreatePromptImageAttachment,\n\tPROMPT_ATTACHMENT_LIMITS,\n\tPromptAttachmentError,\n\ttype PromptAttachmentSource,\n\ttype PromptImageAttachment,\n} from \"./prompt-attachment.ts\";\n\ninterface StoredAttachment {\n\treadonly attachment: PromptImageAttachment;\n\treadonly bytes: Uint8Array;\n}\n\nexport interface AttachmentStoreLimits {\n\tmaxAttachments?: number;\n\tmaxImageBytes?: number;\n\tmaxPixels?: number;\n\tmaxDraftBytes?: number;\n}\n\nexport class AttachmentStore {\n\tprivate readonly entries = new Map<string, StoredAttachment>();\n\tprivate readonly maxAttachments: number;\n\tprivate readonly maxImageBytes: number;\n\tprivate readonly maxPixels: number;\n\tprivate readonly maxDraftBytes: number;\n\n\tconstructor(limits: AttachmentStoreLimits = {}) {\n\t\tthis.maxAttachments = limits.maxAttachments ?? PROMPT_ATTACHMENT_LIMITS.maxAttachments;\n\t\tthis.maxImageBytes = limits.maxImageBytes ?? PROMPT_ATTACHMENT_LIMITS.maxImageBytes;\n\t\tthis.maxPixels = limits.maxPixels ?? PROMPT_ATTACHMENT_LIMITS.maxPixels;\n\t\tthis.maxDraftBytes = limits.maxDraftBytes ?? PROMPT_ATTACHMENT_LIMITS.maxDraftBytes;\n\t}\n\n\t/** Validate + store bytes; returns the attachment record. Throws PromptAttachmentError. */\n\tput(bytes: Uint8Array, source: PromptAttachmentSource): PromptImageAttachment {\n\t\tif (this.entries.size >= this.maxAttachments) {\n\t\t\tthrow new PromptAttachmentError(\"limit-reached\", `Attachment limit reached (${this.maxAttachments}).`);\n\t\t}\n\t\tconst draftBytes = this.totalBytes() + bytes.length;\n\t\tif (draftBytes > this.maxDraftBytes) {\n\t\t\tthrow new PromptAttachmentError(\n\t\t\t\t\"too-large\",\n\t\t\t\t`Draft would exceed ${(this.maxDraftBytes / (1024 * 1024)).toFixed(0)} MiB of attachments.`,\n\t\t\t);\n\t\t}\n\t\tconst attachment = createPromptImageAttachment(bytes, source, {\n\t\t\tmaxImageBytes: this.maxImageBytes,\n\t\t\tmaxPixels: this.maxPixels,\n\t\t});\n\t\t// Content-addressed dedupe: identical bytes reuse one entry.\n\t\tconst existing = [...this.entries.values()].find((entry) => entry.attachment.sha256 === attachment.sha256);\n\t\tif (existing) return existing.attachment;\n\t\tthis.entries.set(attachment.id, { attachment, bytes });\n\t\treturn attachment;\n\t}\n\n\tget(id: string): PromptImageAttachment | undefined {\n\t\treturn this.entries.get(id)?.attachment;\n\t}\n\n\tgetBytes(id: string): Uint8Array | undefined {\n\t\treturn this.entries.get(id)?.bytes;\n\t}\n\n\tremove(id: string): boolean {\n\t\treturn this.entries.delete(id);\n\t}\n\n\tclear(): void {\n\t\tthis.entries.clear();\n\t}\n\n\tlist(): PromptImageAttachment[] {\n\t\treturn [...this.entries.values()].map((entry) => entry.attachment);\n\t}\n\n\ttotalBytes(): number {\n\t\tlet total = 0;\n\t\tfor (const entry of this.entries.values()) total += entry.bytes.length;\n\t\treturn total;\n\t}\n\n\t/**\n\t * Materialize attachments into `ImageContent[]` for\n\t * `session.prompt(text, { images })`. Missing ids throw — callers snapshot\n\t * ids at submit time and must not send half-removed drafts.\n\t */\n\tmaterializeImages(ids: readonly string[]): ImageContent[] {\n\t\treturn ids.map((id) => {\n\t\t\tconst entry = this.entries.get(id);\n\t\t\tif (!entry) throw new PromptAttachmentError(\"empty\", `Attachment ${id} is no longer available.`);\n\t\t\treturn {\n\t\t\t\ttype: \"image\" as const,\n\t\t\t\tdata: Buffer.from(entry.bytes).toString(\"base64\"),\n\t\t\t\tmimeType: entry.attachment.mimeType,\n\t\t\t};\n\t\t});\n\t}\n}\n"]}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * In-memory content-addressed store for prompt image attachments.
3
+ *
4
+ * Owns the raw bytes so the TUI draft survives editor swaps (extension editor,
5
+ * model selector) and only releases entries after the prompt is accepted.
6
+ * Raw base64 never enters logs or journals — materialization happens at the
7
+ * last step, right before `session.prompt(text, { images })`.
8
+ */
9
+ import { createPromptImageAttachment, PROMPT_ATTACHMENT_LIMITS, PromptAttachmentError, } from "./prompt-attachment.js";
10
+ export class AttachmentStore {
11
+ entries = new Map();
12
+ maxAttachments;
13
+ maxImageBytes;
14
+ maxPixels;
15
+ maxDraftBytes;
16
+ constructor(limits = {}) {
17
+ this.maxAttachments = limits.maxAttachments ?? PROMPT_ATTACHMENT_LIMITS.maxAttachments;
18
+ this.maxImageBytes = limits.maxImageBytes ?? PROMPT_ATTACHMENT_LIMITS.maxImageBytes;
19
+ this.maxPixels = limits.maxPixels ?? PROMPT_ATTACHMENT_LIMITS.maxPixels;
20
+ this.maxDraftBytes = limits.maxDraftBytes ?? PROMPT_ATTACHMENT_LIMITS.maxDraftBytes;
21
+ }
22
+ /** Validate + store bytes; returns the attachment record. Throws PromptAttachmentError. */
23
+ put(bytes, source) {
24
+ if (this.entries.size >= this.maxAttachments) {
25
+ throw new PromptAttachmentError("limit-reached", `Attachment limit reached (${this.maxAttachments}).`);
26
+ }
27
+ const draftBytes = this.totalBytes() + bytes.length;
28
+ if (draftBytes > this.maxDraftBytes) {
29
+ throw new PromptAttachmentError("too-large", `Draft would exceed ${(this.maxDraftBytes / (1024 * 1024)).toFixed(0)} MiB of attachments.`);
30
+ }
31
+ const attachment = createPromptImageAttachment(bytes, source, {
32
+ maxImageBytes: this.maxImageBytes,
33
+ maxPixels: this.maxPixels,
34
+ });
35
+ // Content-addressed dedupe: identical bytes reuse one entry.
36
+ const existing = [...this.entries.values()].find((entry) => entry.attachment.sha256 === attachment.sha256);
37
+ if (existing)
38
+ return existing.attachment;
39
+ this.entries.set(attachment.id, { attachment, bytes });
40
+ return attachment;
41
+ }
42
+ get(id) {
43
+ return this.entries.get(id)?.attachment;
44
+ }
45
+ getBytes(id) {
46
+ return this.entries.get(id)?.bytes;
47
+ }
48
+ remove(id) {
49
+ return this.entries.delete(id);
50
+ }
51
+ clear() {
52
+ this.entries.clear();
53
+ }
54
+ list() {
55
+ return [...this.entries.values()].map((entry) => entry.attachment);
56
+ }
57
+ totalBytes() {
58
+ let total = 0;
59
+ for (const entry of this.entries.values())
60
+ total += entry.bytes.length;
61
+ return total;
62
+ }
63
+ /**
64
+ * Materialize attachments into `ImageContent[]` for
65
+ * `session.prompt(text, { images })`. Missing ids throw — callers snapshot
66
+ * ids at submit time and must not send half-removed drafts.
67
+ */
68
+ materializeImages(ids) {
69
+ return ids.map((id) => {
70
+ const entry = this.entries.get(id);
71
+ if (!entry)
72
+ throw new PromptAttachmentError("empty", `Attachment ${id} is no longer available.`);
73
+ return {
74
+ type: "image",
75
+ data: Buffer.from(entry.bytes).toString("base64"),
76
+ mimeType: entry.attachment.mimeType,
77
+ };
78
+ });
79
+ }
80
+ }
81
+ //# sourceMappingURL=attachment-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-store.js","sourceRoot":"","sources":["../../src/core/attachment-store.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EACN,2BAA2B,EAC3B,wBAAwB,EACxB,qBAAqB,GAGrB,MAAM,wBAAwB,CAAC;AAchC,MAAM,OAAO,eAAe;IACV,OAAO,GAAG,IAAI,GAAG,EAA4B,CAAC;IAC9C,cAAc,CAAS;IACvB,aAAa,CAAS;IACtB,SAAS,CAAS;IAClB,aAAa,CAAS;IAEvC,YAAY,MAAM,GAA0B,EAAE,EAAE;QAC/C,IAAI,CAAC,cAAc,GAAG,MAAM,CAAC,cAAc,IAAI,wBAAwB,CAAC,cAAc,CAAC;QACvF,IAAI,CAAC,aAAa,GAAG,MAAM,CAAC,aAAa,IAAI,wBAAwB,CAAC,aAAa,CAAC;QACpF,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC,SAAS,IAAI,wBAAwB,CAAC,SAAS,CAAC;QACxE,IAAI,CAAC,aAAa,GAAG,MAAM,CAAC,aAAa,IAAI,wBAAwB,CAAC,aAAa,CAAC;IAAA,CACpF;IAED,2FAA2F;IAC3F,GAAG,CAAC,KAAiB,EAAE,MAA8B,EAAyB;QAC7E,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC,cAAc,EAAE,CAAC;YAC9C,MAAM,IAAI,qBAAqB,CAAC,eAAe,EAAE,6BAA6B,IAAI,CAAC,cAAc,IAAI,CAAC,CAAC;QACxG,CAAC;QACD,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC;QACpD,IAAI,UAAU,GAAG,IAAI,CAAC,aAAa,EAAE,CAAC;YACrC,MAAM,IAAI,qBAAqB,CAC9B,WAAW,EACX,sBAAsB,CAAC,IAAI,CAAC,aAAa,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,sBAAsB,CAC3F,CAAC;QACH,CAAC;QACD,MAAM,UAAU,GAAG,2BAA2B,CAAC,KAAK,EAAE,MAAM,EAAE;YAC7D,aAAa,EAAE,IAAI,CAAC,aAAa;YACjC,SAAS,EAAE,IAAI,CAAC,SAAS;SACzB,CAAC,CAAC;QACH,6DAA6D;QAC7D,MAAM,QAAQ,GAAG,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,KAAK,UAAU,CAAC,MAAM,CAAC,CAAC;QAC3G,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAC,UAAU,CAAC;QACzC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAC;QACvD,OAAO,UAAU,CAAC;IAAA,CAClB;IAED,GAAG,CAAC,EAAU,EAAqC;QAClD,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,UAAU,CAAC;IAAA,CACxC;IAED,QAAQ,CAAC,EAAU,EAA0B;QAC5C,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC;IAAA,CACnC;IAED,MAAM,CAAC,EAAU,EAAW;QAC3B,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IAAA,CAC/B;IAED,KAAK,GAAS;QACb,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;IAAA,CACrB;IAED,IAAI,GAA4B;QAC/B,OAAO,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IAAA,CACnE;IAED,UAAU,GAAW;QACpB,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE;YAAE,KAAK,IAAI,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC;QACvE,OAAO,KAAK,CAAC;IAAA,CACb;IAED;;;;OAIG;IACH,iBAAiB,CAAC,GAAsB,EAAkB;QACzD,OAAO,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC;YACtB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YACnC,IAAI,CAAC,KAAK;gBAAE,MAAM,IAAI,qBAAqB,CAAC,OAAO,EAAE,cAAc,EAAE,0BAA0B,CAAC,CAAC;YACjG,OAAO;gBACN,IAAI,EAAE,OAAgB;gBACtB,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;gBACjD,QAAQ,EAAE,KAAK,CAAC,UAAU,CAAC,QAAQ;aACnC,CAAC;QAAA,CACF,CAAC,CAAC;IAAA,CACH;CACD","sourcesContent":["/**\n * In-memory content-addressed store for prompt image attachments.\n *\n * Owns the raw bytes so the TUI draft survives editor swaps (extension editor,\n * model selector) and only releases entries after the prompt is accepted.\n * Raw base64 never enters logs or journals — materialization happens at the\n * last step, right before `session.prompt(text, { images })`.\n */\n\nimport type { ImageContent } from \"omk-ai\";\nimport {\n\tcreatePromptImageAttachment,\n\tPROMPT_ATTACHMENT_LIMITS,\n\tPromptAttachmentError,\n\ttype PromptAttachmentSource,\n\ttype PromptImageAttachment,\n} from \"./prompt-attachment.ts\";\n\ninterface StoredAttachment {\n\treadonly attachment: PromptImageAttachment;\n\treadonly bytes: Uint8Array;\n}\n\nexport interface AttachmentStoreLimits {\n\tmaxAttachments?: number;\n\tmaxImageBytes?: number;\n\tmaxPixels?: number;\n\tmaxDraftBytes?: number;\n}\n\nexport class AttachmentStore {\n\tprivate readonly entries = new Map<string, StoredAttachment>();\n\tprivate readonly maxAttachments: number;\n\tprivate readonly maxImageBytes: number;\n\tprivate readonly maxPixels: number;\n\tprivate readonly maxDraftBytes: number;\n\n\tconstructor(limits: AttachmentStoreLimits = {}) {\n\t\tthis.maxAttachments = limits.maxAttachments ?? PROMPT_ATTACHMENT_LIMITS.maxAttachments;\n\t\tthis.maxImageBytes = limits.maxImageBytes ?? PROMPT_ATTACHMENT_LIMITS.maxImageBytes;\n\t\tthis.maxPixels = limits.maxPixels ?? PROMPT_ATTACHMENT_LIMITS.maxPixels;\n\t\tthis.maxDraftBytes = limits.maxDraftBytes ?? PROMPT_ATTACHMENT_LIMITS.maxDraftBytes;\n\t}\n\n\t/** Validate + store bytes; returns the attachment record. Throws PromptAttachmentError. */\n\tput(bytes: Uint8Array, source: PromptAttachmentSource): PromptImageAttachment {\n\t\tif (this.entries.size >= this.maxAttachments) {\n\t\t\tthrow new PromptAttachmentError(\"limit-reached\", `Attachment limit reached (${this.maxAttachments}).`);\n\t\t}\n\t\tconst draftBytes = this.totalBytes() + bytes.length;\n\t\tif (draftBytes > this.maxDraftBytes) {\n\t\t\tthrow new PromptAttachmentError(\n\t\t\t\t\"too-large\",\n\t\t\t\t`Draft would exceed ${(this.maxDraftBytes / (1024 * 1024)).toFixed(0)} MiB of attachments.`,\n\t\t\t);\n\t\t}\n\t\tconst attachment = createPromptImageAttachment(bytes, source, {\n\t\t\tmaxImageBytes: this.maxImageBytes,\n\t\t\tmaxPixels: this.maxPixels,\n\t\t});\n\t\t// Content-addressed dedupe: identical bytes reuse one entry.\n\t\tconst existing = [...this.entries.values()].find((entry) => entry.attachment.sha256 === attachment.sha256);\n\t\tif (existing) return existing.attachment;\n\t\tthis.entries.set(attachment.id, { attachment, bytes });\n\t\treturn attachment;\n\t}\n\n\tget(id: string): PromptImageAttachment | undefined {\n\t\treturn this.entries.get(id)?.attachment;\n\t}\n\n\tgetBytes(id: string): Uint8Array | undefined {\n\t\treturn this.entries.get(id)?.bytes;\n\t}\n\n\tremove(id: string): boolean {\n\t\treturn this.entries.delete(id);\n\t}\n\n\tclear(): void {\n\t\tthis.entries.clear();\n\t}\n\n\tlist(): PromptImageAttachment[] {\n\t\treturn [...this.entries.values()].map((entry) => entry.attachment);\n\t}\n\n\ttotalBytes(): number {\n\t\tlet total = 0;\n\t\tfor (const entry of this.entries.values()) total += entry.bytes.length;\n\t\treturn total;\n\t}\n\n\t/**\n\t * Materialize attachments into `ImageContent[]` for\n\t * `session.prompt(text, { images })`. Missing ids throw — callers snapshot\n\t * ids at submit time and must not send half-removed drafts.\n\t */\n\tmaterializeImages(ids: readonly string[]): ImageContent[] {\n\t\treturn ids.map((id) => {\n\t\t\tconst entry = this.entries.get(id);\n\t\t\tif (!entry) throw new PromptAttachmentError(\"empty\", `Attachment ${id} is no longer available.`);\n\t\t\treturn {\n\t\t\t\ttype: \"image\" as const,\n\t\t\t\tdata: Buffer.from(entry.bytes).toString(\"base64\"),\n\t\t\t\tmimeType: entry.attachment.mimeType,\n\t\t\t};\n\t\t});\n\t}\n}\n"]}
@@ -4,6 +4,14 @@ export interface CommandVerdict {
4
4
  rule: string;
5
5
  reason: string;
6
6
  }
7
+ /**
8
+ * Collect the command strings bash executes via command/process substitution:
9
+ * `$(cmd)`, backticks, `<(cmd)`, and `>(cmd)`. Single-quoted regions never
10
+ * expand and are skipped; double-quoted regions expand, so `$()`/backticks
11
+ * inside them are collected. Bodies are recursed into for nested
12
+ * substitutions up to {@link MAX_SUBSTITUTION_DEPTH}.
13
+ */
14
+ export declare function extractCommandSubstitutions(command: string, depth?: number): string[];
7
15
  export declare function classifyShellCommand(command: string): CommandVerdict;
8
16
  export declare function isDestructiveFilesystem(command: string): boolean;
9
17
  export declare function isProtectedGitOperation(command: string): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"command-safety.d.ts","sourceRoot":"","sources":["../../src/core/command-safety.ts"],"names":[],"mappings":"AACA,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,CAAC;AAExD,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CACf;AA8kBD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,cAAc,CAEpE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEhE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEhE;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAE9D","sourcesContent":["// allow: SIZE_OK - legacy safety rules table; this change only preserves existing typed behavior.\nexport type CommandRisk = \"block\" | \"confirm\" | \"allow\";\n\nexport interface CommandVerdict {\n\trisk: CommandRisk;\n\trule: string;\n\treason: string;\n}\n\ninterface CommandPrefixResult {\n\ttokens: string[];\n\tprivilege: string | undefined;\n}\n\nconst RISK_RANK: Readonly<Record<CommandRisk, number>> = {\n\tallow: 0,\n\tconfirm: 1,\n\tblock: 2,\n};\n\nconst PRIVILEGE_COMMANDS = new Set([\"sudo\", \"su\", \"doas\", \"pkexec\"]);\nconst SUDO_OPTIONS_WITH_VALUE = new Set([\n\t\"-C\",\n\t\"--close-from\",\n\t\"-g\",\n\t\"--group\",\n\t\"-h\",\n\t\"--host\",\n\t\"-p\",\n\t\"--prompt\",\n\t\"-T\",\n\t\"--command-timeout\",\n\t\"-u\",\n\t\"--user\",\n]);\nconst GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set([\"-C\", \"-c\", \"--git-dir\", \"--work-tree\", \"--namespace\"]);\n\n/** Maximum recursion depth when unwrapping shell wrappers (bash -c, eval, xargs, find -exec). */\nconst MAX_WRAP_DEPTH = 6;\n\n/** Executables that run an inline command string passed via a `-c`-style flag. */\nconst SHELL_WRAPPERS = new Set([\"bash\", \"sh\", \"zsh\", \"dash\", \"ksh\", \"ash\"]);\n\n/**\n * Script interpreters that execute an inline program string (via a -c/-e/-r\n * style flag). Running inline code can execute arbitrary shell commands, so a\n * matched destructive/exec keyword inside the program escalates the call to\n * confirm-tier. A plain script-file path stays allow-tier.\n */\nconst INTERPRETER_INLINE_FLAGS: Readonly<Record<string, readonly string[]>> = {\n\tpython: [\"-c\"],\n\tpython2: [\"-c\"],\n\tpython3: [\"-c\"],\n\tnode: [\"-e\", \"--eval\", \"-p\", \"--print\"],\n\tperl: [\"-e\", \"-E\"],\n\truby: [\"-e\", \"-E\"],\n\tphp: [\"-r\", \"--run\"],\n\tlua: [\"-e\"],\n\texpect: [\"-c\"],\n};\n\n/**\n * Patterns that indicate an inline program invokes a shell command or\n * performs a destructive filesystem operation. Matching any of these inside\n * a `-c`/`-e`/`-r` program string escalates the call to confirm-tier.\n */\nconst INLINE_DESTRUCTIVE_PATTERNS: readonly RegExp[] = [\n\t/\\bos\\.system\\s*\\(/,\n\t/\\bos\\.remove\\s*\\(/,\n\t/\\bos\\.unlink\\s*\\(/,\n\t/\\bshutil\\.rmtree\\s*\\(/,\n\t/\\bsubprocess\\.(?:call|run|check_call|check_output|Popen)\\s*\\(/,\n\t/\\bexec(?:Sync|File)?\\s*\\(/,\n\t/\\bspawn(?:Sync)?\\s*\\(/,\n\t/\\bchild_process\\b/,\n\t/\\brequire\\s*\\(\\s*['\"]child_process['\"]\\s*\\)/,\n\t/\\b(?:system|popen)\\s*\\(/,\n\t/`[^`]*\\brm\\s+-/m,\n\t/\\$\\(\\s*[^)]*\\brm\\s+-/,\n];\n\n/** xargs options that consume a following value token before the wrapped command begins. */\nconst XARGS_OPTIONS_WITH_VALUE = new Set([\n\t\"-I\",\n\t\"-i\",\n\t\"-n\",\n\t\"-L\",\n\t\"-P\",\n\t\"-d\",\n\t\"-E\",\n\t\"-s\",\n\t\"-a\",\n\t\"--max-args\",\n\t\"--max-procs\",\n\t\"--delimiter\",\n\t\"--arg-file\",\n\t\"--max-lines\",\n\t\"--replace\",\n]);\n\nconst HOME_VARIABLE_RM_TARGETS = new Set([\"$home\", \"$\" + \"{home}\", \"$home/\", \"$\" + \"{home}/\"]);\n\nfunction verdict(risk: CommandRisk, rule: string, reason: string): CommandVerdict {\n\treturn { risk, rule, reason };\n}\n\nfunction allowVerdict(): CommandVerdict {\n\treturn verdict(\"allow\", \"command.allow\", \"No destructive or protected command pattern matched.\");\n}\n\nfunction isEnvAssignment(token: string | undefined): boolean {\n\treturn token !== undefined && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);\n}\n\nfunction tokenizeShellSegment(segment: string): string[] {\n\tconst tokens: string[] = [];\n\tlet current = \"\";\n\tlet quote: string | undefined;\n\tlet escaped = false;\n\n\tfor (const character of segment.trim()) {\n\t\tif (escaped) {\n\t\t\tcurrent += character;\n\t\t\tescaped = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"\\\\\" && quote !== \"'\") {\n\t\t\tescaped = true;\n\t\t\tcontinue;\n\t\t}\n\t\tif (quote) {\n\t\t\tif (character === quote) {\n\t\t\t\tquote = undefined;\n\t\t\t} else {\n\t\t\t\tcurrent += character;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"'\" || character === '\"') {\n\t\t\tquote = character;\n\t\t\tcontinue;\n\t\t}\n\t\tif (/\\s/.test(character)) {\n\t\t\tif (current) {\n\t\t\t\ttokens.push(current);\n\t\t\t\tcurrent = \"\";\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tcurrent += character;\n\t}\n\n\tif (escaped) current += \"\\\\\";\n\tif (current) tokens.push(current);\n\treturn tokens;\n}\n\nfunction splitShellCommands(command: string): string[] {\n\tconst segments: string[] = [];\n\tlet current = \"\";\n\tlet quote: string | undefined;\n\tlet escaped = false;\n\n\tfor (let index = 0; index < command.length; index += 1) {\n\t\tconst character = command[index];\n\t\tconst next = command[index + 1];\n\t\tif (escaped) {\n\t\t\tcurrent += character;\n\t\t\tescaped = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"\\\\\" && quote !== \"'\") {\n\t\t\tescaped = true;\n\t\t\tcurrent += character;\n\t\t\tcontinue;\n\t\t}\n\t\tif (quote) {\n\t\t\tcurrent += character;\n\t\t\tif (character === quote) quote = undefined;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"'\" || character === '\"') {\n\t\t\tquote = character;\n\t\t\tcurrent += character;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \";\" || character === \"|\" || character === \"\\n\" || character === \"\\r\") {\n\t\t\tif (current.trim()) segments.push(current.trim());\n\t\t\tcurrent = \"\";\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"&\") {\n\t\t\tif (current.trim()) segments.push(current.trim());\n\t\t\tcurrent = \"\";\n\t\t\tif (next === \"&\") index += 1;\n\t\t\tcontinue;\n\t\t}\n\t\tcurrent += character;\n\t}\n\n\tif (current.trim()) segments.push(current.trim());\n\treturn segments;\n}\n\nfunction skipEnvironmentPrefix(tokens: readonly string[], startIndex: number): number {\n\tlet index = startIndex;\n\twhile (isEnvAssignment(tokens[index])) index += 1;\n\tif (tokens[index]?.toLowerCase() !== \"env\") return index;\n\n\tindex += 1;\n\twhile (index < tokens.length) {\n\t\tconst token = tokens[index];\n\t\tif (isEnvAssignment(token)) {\n\t\t\tindex += 1;\n\t\t\tcontinue;\n\t\t}\n\t\tif (token?.startsWith(\"-\")) {\n\t\t\tindex += 1;\n\t\t\tcontinue;\n\t\t}\n\t\tbreak;\n\t}\n\treturn index;\n}\n\nfunction optionConsumesValue(option: string, optionsWithValue: ReadonlySet<string>): boolean {\n\tif (option.includes(\"=\")) return false;\n\treturn optionsWithValue.has(option);\n}\n\nfunction skipPrivilegeOptions(tokens: readonly string[], startIndex: number, privilege: string): number {\n\tif (privilege !== \"sudo\" && privilege !== \"doas\") return startIndex;\n\n\tlet index = startIndex;\n\twhile (index < tokens.length) {\n\t\tconst token = tokens[index];\n\t\tif (!token?.startsWith(\"-\")) break;\n\t\tindex += 1;\n\t\tif (token === \"--\") break;\n\t\tif (optionConsumesValue(token, SUDO_OPTIONS_WITH_VALUE)) index += 1;\n\t}\n\treturn index;\n}\n\nfunction stripCommandPrefixes(tokens: readonly string[]): CommandPrefixResult {\n\tlet index = skipEnvironmentPrefix(tokens, 0);\n\tconst privilege = tokens[index]?.toLowerCase();\n\tif (!PRIVILEGE_COMMANDS.has(privilege ?? \"\")) {\n\t\treturn { tokens: tokens.slice(index), privilege: undefined };\n\t}\n\n\tindex += 1;\n\tindex = skipPrivilegeOptions(tokens, index, privilege ?? \"\");\n\tindex = skipEnvironmentPrefix(tokens, index);\n\treturn { tokens: tokens.slice(index), privilege };\n}\n\nfunction hasForkBomb(command: string): boolean {\n\treturn command.replace(/\\s/g, \"\").includes(\":(){:|:&};:\");\n}\n\nfunction normalizePathTarget(target: string): string {\n\treturn target.replace(/\\/+$/g, (match) => (target === match ? \"/\" : \"\")).replace(/\\/+/g, \"/\");\n}\n\nfunction classifyRmTarget(target: string): CommandVerdict | null {\n\tconst normalized = normalizePathTarget(target);\n\tif (normalized === \"/\" || normalized === \"/*\" || normalized === \"/.*\") {\n\t\treturn verdict(\"block\", \"fs.rm_rf_root\", \"Recursive forced rm targets filesystem root.\");\n\t}\n\tif (normalized === \"~\" || normalized === \"~/\" || normalized === \"~/*\" || normalized === \"~/.*\") {\n\t\treturn verdict(\"block\", \"fs.rm_rf_home\", \"Recursive forced rm targets the home directory.\");\n\t}\n\t// $HOME / ${HOME} expands to the user home directory at runtime.\n\tconst lower = normalized.toLowerCase();\n\tif (HOME_VARIABLE_RM_TARGETS.has(lower)) {\n\t\treturn verdict(\n\t\t\t\"confirm\",\n\t\t\t\"fs.rm_rf_home_var\",\n\t\t\t\"Recursive forced rm targets $HOME, which expands to the user home directory.\",\n\t\t);\n\t}\n\t// ~username targets another user home directory.\n\tif (/^~[A-Za-z0-9._-]/.test(normalized)) {\n\t\treturn verdict(\"confirm\", \"fs.rm_rf_user_home\", \"Recursive forced rm targets another user home directory.\");\n\t}\n\t// \".\" or \"./\" — current working directory (project root deletion risk).\n\tif (normalized === \".\") {\n\t\treturn verdict(\"confirm\", \"fs.rm_rf_cwd\", \"Recursive forced rm targets the current working directory.\");\n\t}\n\t// \"..\" — parent directory.\n\tif (normalized === \"..\") {\n\t\treturn verdict(\"confirm\", \"fs.rm_rf_parent\", \"Recursive forced rm targets the parent directory.\");\n\t}\n\treturn null;\n}\n\nfunction classifyRm(tokens: readonly string[]): CommandVerdict | null {\n\tlet hasRecursive = false;\n\tlet hasForce = false;\n\tlet endOfOptions = false;\n\tconst targets: string[] = [];\n\n\tfor (const token of tokens.slice(1)) {\n\t\tif (!endOfOptions && token === \"--\") {\n\t\t\tendOfOptions = true;\n\t\t\tcontinue;\n\t\t}\n\t\tif (!endOfOptions && token.startsWith(\"-\") && token !== \"-\") {\n\t\t\tif (token === \"--recursive\") hasRecursive = true;\n\t\t\tif (token === \"--force\") hasForce = true;\n\t\t\tif (!token.startsWith(\"--\")) {\n\t\t\t\tconst flagCharacters = token.slice(1).split(\"\");\n\t\t\t\tif (flagCharacters.some((flag) => flag === \"r\" || flag === \"R\")) hasRecursive = true;\n\t\t\t\tif (flagCharacters.includes(\"f\")) hasForce = true;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\ttargets.push(token);\n\t}\n\n\tif (!hasRecursive || !hasForce) return null;\n\tfor (const target of targets) {\n\t\tconst targetVerdict = classifyRmTarget(target);\n\t\tif (targetVerdict) return targetVerdict;\n\t}\n\treturn null;\n}\n\nfunction isBlockDeviceOutput(token: string): boolean {\n\treturn /^of=\\/dev\\/(?:sd[a-z][a-z0-9]*|nvme[0-9a-z]+|disk(?:[0-9].*|\\/.*|$))/i.test(token);\n}\n\nfunction classifyDestructiveFilesystemCommand(command: string): CommandVerdict | null {\n\tif (hasForkBomb(command)) {\n\t\treturn verdict(\"block\", \"process.fork_bomb\", \"Shell fork bomb pattern would exhaust process resources.\");\n\t}\n\n\tconst { tokens } = stripCommandPrefixes(tokenizeShellSegment(command));\n\tconst executable = tokens[0]?.toLowerCase();\n\tif (!executable) return null;\n\tif (executable === \"rm\") return classifyRm(tokens);\n\tif (executable.startsWith(\"mkfs\")) {\n\t\treturn verdict(\"block\", \"fs.mkfs\", \"Filesystem formatting commands are blocked.\");\n\t}\n\tif (executable === \"dd\" && tokens.slice(1).some(isBlockDeviceOutput)) {\n\t\treturn verdict(\"block\", \"fs.dd_block_device\", \"dd writes directly to a block-device output path.\");\n\t}\n\treturn null;\n}\n\nfunction findGitCommand(tokens: readonly string[]): { command: string; args: string[]; originalArgs: string[] } | null {\n\tif (tokens[0]?.toLowerCase() !== \"git\") return null;\n\tlet index = 1;\n\twhile (index < tokens.length && tokens[index]?.startsWith(\"-\")) {\n\t\tconst option = tokens[index];\n\t\tconst normalizedOption = option.toLowerCase();\n\t\tindex += 1;\n\t\tif (option === \"--\") break;\n\t\tif (\n\t\t\toptionConsumesValue(option, GIT_GLOBAL_OPTIONS_WITH_VALUE) ||\n\t\t\toptionConsumesValue(normalizedOption, GIT_GLOBAL_OPTIONS_WITH_VALUE)\n\t\t) {\n\t\t\tindex += 1;\n\t\t}\n\t}\n\tconst command = tokens[index]?.toLowerCase();\n\tif (!command) return null;\n\tconst originalArgs = tokens.slice(index + 1);\n\treturn {\n\t\tcommand,\n\t\targs: originalArgs.map((argument) => argument.toLowerCase()),\n\t\toriginalArgs,\n\t};\n}\n\nfunction hasGitCleanForceDirectory(args: readonly string[]): boolean {\n\tlet hasForce = false;\n\tlet hasDirectory = false;\n\tfor (const arg of args) {\n\t\tif (arg === \"--force\") hasForce = true;\n\t\tif (arg === \"-f\") hasForce = true;\n\t\tif (arg === \"-d\") hasDirectory = true;\n\t\tif (arg.startsWith(\"-\") && !arg.startsWith(\"--\")) {\n\t\t\tconst flags = arg.slice(1).split(\"\");\n\t\t\tif (flags.includes(\"f\")) hasForce = true;\n\t\t\tif (flags.includes(\"d\")) hasDirectory = true;\n\t\t}\n\t}\n\treturn hasForce && hasDirectory;\n}\n\nfunction classifyProtectedGitCommand(command: string): CommandVerdict | null {\n\tconst { tokens } = stripCommandPrefixes(tokenizeShellSegment(command));\n\tconst gitCommand = findGitCommand(tokens);\n\tif (!gitCommand) return null;\n\n\tif (gitCommand.command === \"reset\" && gitCommand.args.includes(\"--hard\")) {\n\t\treturn verdict(\"confirm\", \"git.reset_hard\", \"git reset --hard can discard worktree changes.\");\n\t}\n\tif (gitCommand.command === \"restore\") {\n\t\tconst targetAll = gitCommand.originalArgs.includes(\".\") || gitCommand.originalArgs.includes(\"*\");\n\t\tconst rewritesWorktree = gitCommand.args.includes(\"--worktree\") || gitCommand.args.includes(\"--source\");\n\t\tif (targetAll || rewritesWorktree) {\n\t\t\treturn verdict(\"confirm\", \"git.restore\", \"git restore can discard or overwrite local worktree changes.\");\n\t\t}\n\t}\n\tif (gitCommand.command === \"checkout\" && gitCommand.originalArgs.includes(\".\")) {\n\t\treturn verdict(\"confirm\", \"git.checkout_dot\", \"git checkout . can overwrite local worktree changes.\");\n\t}\n\tif (gitCommand.command === \"clean\" && hasGitCleanForceDirectory(gitCommand.args)) {\n\t\treturn verdict(\"confirm\", \"git.clean_force\", \"git clean -fd can delete untracked files and directories.\");\n\t}\n\tif (gitCommand.command === \"stash\" && gitCommand.originalArgs.length === 0) {\n\t\treturn verdict(\"confirm\", \"git.stash_bare\", \"Bare git stash can hide local worktree changes.\");\n\t}\n\tif (gitCommand.command === \"commit\" && gitCommand.args.includes(\"--no-verify\")) {\n\t\treturn verdict(\"confirm\", \"git.no_verify\", \"git commit --no-verify bypasses repository verification hooks.\");\n\t}\n\tif (gitCommand.command === \"push\" && gitCommand.args.includes(\"--no-verify\")) {\n\t\treturn verdict(\"confirm\", \"git.push_no_verify\", \"git push --no-verify bypasses remote verification hooks.\");\n\t}\n\tif (\n\t\tgitCommand.command === \"push\" &&\n\t\t(gitCommand.originalArgs.includes(\"-f\") ||\n\t\t\tgitCommand.args.includes(\"--force\") ||\n\t\t\tgitCommand.args.includes(\"--force-with-lease\"))\n\t) {\n\t\treturn verdict(\"confirm\", \"git.force_push\", \"Force-pushing can rewrite remote history.\");\n\t}\n\treturn null;\n}\n\nfunction classifyPrivilegeCommand(command: string): CommandVerdict | null {\n\tconst tokens = tokenizeShellSegment(command);\n\tconst index = skipEnvironmentPrefix(tokens, 0);\n\tconst privilege = tokens[index]?.toLowerCase();\n\tif (!PRIVILEGE_COMMANDS.has(privilege ?? \"\")) return null;\n\treturn verdict(\"confirm\", `priv.${privilege}`, `${privilege} requires explicit per-command confirmation.`);\n}\n\n/**\n * Locate the inline command-string argument for a `shell -c \"<cmd>\"` invocation.\n * Returns the token index of the command string, or -1 when the shell runs a\n * script file (no inline command to classify).\n */\nfunction findShellCommandStringIndex(tokens: readonly string[]): number {\n\tfor (let index = 1; index < tokens.length; index += 1) {\n\t\tconst token = tokens[index];\n\t\tif (!token.startsWith(\"-\")) return -1;\n\t\tif (/^-[a-z]*c$/i.test(token)) {\n\t\t\treturn index + 1 < tokens.length ? index + 1 : -1;\n\t\t}\n\t}\n\treturn -1;\n}\n\n/**\n * Locate the inline program-string argument for an interpreter invocation\n * (`python -c`, `node -e`, `perl -e`, ...). Returns the token index of the\n * program string, or -1 when the interpreter runs a script file (no inline\n * program to classify).\n */\nfunction findInterpreterInlineIndex(tokens: readonly string[], flags: readonly string[]): number {\n\tfor (let index = 1; index < tokens.length; index += 1) {\n\t\tconst token = tokens[index];\n\t\tif (token === \"--\") return -1;\n\t\tif (!token.startsWith(\"-\")) return -1;\n\t\tif (flags.includes(token) || flags.includes(token.toLowerCase())) {\n\t\t\treturn index + 1 < tokens.length ? index + 1 : -1;\n\t\t}\n\t}\n\treturn -1;\n}\n\n/** Drop leading xargs options (and their values) to expose the wrapped command. */\nfunction stripXargsOptions(args: readonly string[]): string[] {\n\tlet index = 0;\n\twhile (index < args.length) {\n\t\tconst token = args[index];\n\t\tif (!token.startsWith(\"-\") || token === \"-\") break;\n\t\tindex += 1;\n\t\tif (token === \"--\") break;\n\t\tif (!token.includes(\"=\") && XARGS_OPTIONS_WITH_VALUE.has(token)) index += 1;\n\t}\n\treturn args.slice(index);\n}\n\n/** Extract the command tokens of a `find ... -exec <cmd> {} ;|+` clause, if present. */\nfunction extractFindExec(tokens: readonly string[]): string[] | null {\n\tfor (let index = 1; index < tokens.length; index += 1) {\n\t\tconst token = tokens[index].toLowerCase();\n\t\tif (token === \"-exec\" || token === \"-execdir\" || token === \"-ok\" || token === \"-okdir\") {\n\t\t\tconst collected: string[] = [];\n\t\t\tfor (let inner = index + 1; inner < tokens.length; inner += 1) {\n\t\t\t\tconst piece = tokens[inner];\n\t\t\t\tif (piece === \";\" || piece === \"+\") break;\n\t\t\t\tif (piece === \"{}\") continue;\n\t\t\t\tcollected.push(piece);\n\t\t\t}\n\t\t\treturn collected.length > 0 ? collected : null;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Classify the effective command hidden inside a wrapper executable so a\n * destructive or protected command cannot be smuggled past the classifier via\n * `bash -c`, `eval`, `xargs`, or `find -exec`. Returns null when the segment is\n * not a recognized wrapper.\n */\nfunction classifyWrappedCommand(command: string, depth: number): CommandVerdict | null {\n\tif (depth >= MAX_WRAP_DEPTH) return null;\n\tconst { tokens } = stripCommandPrefixes(tokenizeShellSegment(command));\n\tconst executable = tokens[0]?.toLowerCase();\n\tif (!executable) return null;\n\n\tif (SHELL_WRAPPERS.has(executable)) {\n\t\tconst innerIndex = findShellCommandStringIndex(tokens);\n\t\tconst inner = innerIndex === -1 ? undefined : tokens[innerIndex];\n\t\treturn inner ? classifyShellCommandInternal(inner, depth + 1) : null;\n\t}\n\tif (executable === \"eval\") {\n\t\tconst inner = tokens.slice(1).join(\" \").trim();\n\t\treturn inner ? classifyShellCommandInternal(inner, depth + 1) : null;\n\t}\n\tif (executable === \"xargs\") {\n\t\tconst inner = stripXargsOptions(tokens.slice(1)).join(\" \").trim();\n\t\treturn inner ? classifyShellCommandInternal(inner, depth + 1) : null;\n\t}\n\tif (executable === \"find\") {\n\t\tconst lowered = tokens.map((token) => token.toLowerCase());\n\t\tconst hasDelete = lowered.includes(\"-delete\");\n\t\tconst execTokens = extractFindExec(tokens);\n\t\tconst inner = execTokens?.join(\" \").trim();\n\t\tconst innerVerdict = inner ? classifyShellCommandInternal(inner, depth + 1) : null;\n\t\tif (hasDelete) {\n\t\t\tconst deleteVerdict = verdict(\n\t\t\t\t\"confirm\",\n\t\t\t\t\"fs.find_delete\",\n\t\t\t\t\"find -delete removes matched files and directories.\",\n\t\t\t);\n\t\t\tif (!innerVerdict || RISK_RANK[deleteVerdict.risk] > RISK_RANK[innerVerdict.risk]) {\n\t\t\t\treturn deleteVerdict;\n\t\t\t}\n\t\t}\n\t\treturn innerVerdict ?? null;\n\t}\n\tconst inlineFlags = INTERPRETER_INLINE_FLAGS[executable];\n\tif (inlineFlags) {\n\t\tconst innerIndex = findInterpreterInlineIndex(tokens, inlineFlags);\n\t\tif (innerIndex === -1) return null; // script file path — allow\n\t\tconst program = tokens[innerIndex];\n\t\tif (INLINE_DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(program))) {\n\t\t\treturn verdict(\n\t\t\t\t\"confirm\",\n\t\t\t\t`interp.${executable}_inline_destructive`,\n\t\t\t\t`${executable} inline program can execute destructive shell commands.`,\n\t\t\t);\n\t\t}\n\t\treturn null;\n\t}\n\treturn null;\n}\n\nfunction classifySingleCommand(command: string, depth: number): CommandVerdict {\n\tconst trimmed = command.trim();\n\tif (!trimmed) return allowVerdict();\n\n\tconst destructiveFilesystem = classifyDestructiveFilesystemCommand(trimmed);\n\tif (destructiveFilesystem) return destructiveFilesystem;\n\n\tconst wrapped = classifyWrappedCommand(trimmed, depth);\n\tif (wrapped && wrapped.risk === \"block\") return wrapped;\n\n\tconst protectedGit = classifyProtectedGitCommand(trimmed);\n\tif (protectedGit) return protectedGit;\n\n\tconst privilege = classifyPrivilegeCommand(trimmed);\n\tif (privilege) return privilege;\n\n\tif (wrapped && RISK_RANK[wrapped.risk] > RISK_RANK.allow) return wrapped;\n\n\treturn allowVerdict();\n}\n\nfunction classifyShellCommandInternal(command: string, depth: number): CommandVerdict {\n\tlet selected = classifySingleCommand(command, depth);\n\tfor (const segment of splitShellCommands(command)) {\n\t\tconst candidate = classifySingleCommand(segment, depth);\n\t\tif (RISK_RANK[candidate.risk] > RISK_RANK[selected.risk]) selected = candidate;\n\t\tif (selected.risk === \"block\") break;\n\t}\n\treturn selected;\n}\n\nexport function classifyShellCommand(command: string): CommandVerdict {\n\treturn classifyShellCommandInternal(command, 0);\n}\n\nexport function isDestructiveFilesystem(command: string): boolean {\n\treturn classifyDestructiveFilesystemCommand(command) !== null;\n}\n\nexport function isProtectedGitOperation(command: string): boolean {\n\treturn classifyProtectedGitCommand(command) !== null;\n}\n\nexport function isPrivilegeEscalation(command: string): boolean {\n\treturn classifyPrivilegeCommand(command) !== null;\n}\n"]}
1
+ {"version":3,"file":"command-safety.d.ts","sourceRoot":"","sources":["../../src/core/command-safety.ts"],"names":[],"mappings":"AACA,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,CAAC;AAExD,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CACf;AA6ID;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,SAAI,GAAG,MAAM,EAAE,CAgDhF;AAugBD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,cAAc,CAEpE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEhE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEhE;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAE9D","sourcesContent":["// allow: SIZE_OK - legacy safety rules table; this change only preserves existing typed behavior.\nexport type CommandRisk = \"block\" | \"confirm\" | \"allow\";\n\nexport interface CommandVerdict {\n\trisk: CommandRisk;\n\trule: string;\n\treason: string;\n}\n\ninterface CommandPrefixResult {\n\ttokens: string[];\n\tprivilege: string | undefined;\n}\n\nconst RISK_RANK: Readonly<Record<CommandRisk, number>> = {\n\tallow: 0,\n\tconfirm: 1,\n\tblock: 2,\n};\n\nconst PRIVILEGE_COMMANDS = new Set([\"sudo\", \"su\", \"doas\", \"pkexec\"]);\nconst SUDO_OPTIONS_WITH_VALUE = new Set([\n\t\"-C\",\n\t\"--close-from\",\n\t\"-g\",\n\t\"--group\",\n\t\"-h\",\n\t\"--host\",\n\t\"-p\",\n\t\"--prompt\",\n\t\"-T\",\n\t\"--command-timeout\",\n\t\"-u\",\n\t\"--user\",\n]);\nconst GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set([\"-C\", \"-c\", \"--git-dir\", \"--work-tree\", \"--namespace\"]);\n\n/** Maximum recursion depth when unwrapping shell wrappers (bash -c, eval, xargs, find -exec). */\nconst MAX_WRAP_DEPTH = 6;\n\n/** Maximum recursion depth when extracting nested command substitutions ($(), backticks). */\nconst MAX_SUBSTITUTION_DEPTH = 6;\n\n/** Executables that run an inline command string passed via a `-c`-style flag. */\nconst SHELL_WRAPPERS = new Set([\"bash\", \"sh\", \"zsh\", \"dash\", \"ksh\", \"ash\"]);\n\n/**\n * Script interpreters that execute an inline program string (via a -c/-e/-r\n * style flag). Running inline code can execute arbitrary shell commands, so a\n * matched destructive/exec keyword inside the program escalates the call to\n * confirm-tier. A plain script-file path stays allow-tier.\n */\nconst INTERPRETER_INLINE_FLAGS: Readonly<Record<string, readonly string[]>> = {\n\tpython: [\"-c\"],\n\tpython2: [\"-c\"],\n\tpython3: [\"-c\"],\n\tnode: [\"-e\", \"--eval\", \"-p\", \"--print\"],\n\tperl: [\"-e\", \"-E\"],\n\truby: [\"-e\", \"-E\"],\n\tphp: [\"-r\", \"--run\"],\n\tlua: [\"-e\"],\n\texpect: [\"-c\"],\n};\n\n/**\n * Patterns that indicate an inline program invokes a shell command or\n * performs a destructive filesystem operation. Matching any of these inside\n * a `-c`/`-e`/`-r` program string escalates the call to confirm-tier.\n */\nconst INLINE_DESTRUCTIVE_PATTERNS: readonly RegExp[] = [\n\t/\\bos\\.system\\s*\\(/,\n\t/\\bos\\.remove\\s*\\(/,\n\t/\\bos\\.unlink\\s*\\(/,\n\t/\\bshutil\\.rmtree\\s*\\(/,\n\t/\\bsubprocess\\.(?:call|run|check_call|check_output|Popen)\\s*\\(/,\n\t/\\bexec(?:Sync|File)?\\s*\\(/,\n\t/\\bspawn(?:Sync)?\\s*\\(/,\n\t/\\bchild_process\\b/,\n\t/\\brequire\\s*\\(\\s*['\"]child_process['\"]\\s*\\)/,\n\t/\\b(?:system|popen)\\s*\\(/,\n\t/`[^`]*\\brm\\s+-/m,\n\t/\\$\\(\\s*[^)]*\\brm\\s+-/,\n];\n\n/** xargs options that consume a following value token before the wrapped command begins. */\nconst XARGS_OPTIONS_WITH_VALUE = new Set([\n\t\"-I\",\n\t\"-i\",\n\t\"-n\",\n\t\"-L\",\n\t\"-P\",\n\t\"-d\",\n\t\"-E\",\n\t\"-s\",\n\t\"-a\",\n\t\"--max-args\",\n\t\"--max-procs\",\n\t\"--delimiter\",\n\t\"--arg-file\",\n\t\"--max-lines\",\n\t\"--replace\",\n]);\n\nconst HOME_VARIABLE_RM_TARGETS = new Set([\"$home\", \"$\" + \"{home}\", \"$home/\", \"$\" + \"{home}/\"]);\n\n/**\n * Index of the parenthesis matching the one at {@link openIndex}, honoring\n * quotes and backslash escapes so a `)` inside quotes does not terminate the\n * substitution body. Returns -1 when unmatched.\n */\nfunction findMatchingParen(text: string, openIndex: number): number {\n\tlet depth = 0;\n\tlet quote: \"'\" | '\"' | undefined;\n\tfor (let index = openIndex; index < text.length; index += 1) {\n\t\tconst character = text[index];\n\t\tif (character === \"\\\\\") {\n\t\t\tindex += 1;\n\t\t\tcontinue;\n\t\t}\n\t\tif (quote) {\n\t\t\tif (character === quote) quote = undefined;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"'\" || character === '\"') {\n\t\t\tquote = character;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"(\") depth += 1;\n\t\telse if (character === \")\") {\n\t\t\tdepth -= 1;\n\t\t\tif (depth === 0) return index;\n\t\t}\n\t}\n\treturn -1;\n}\n\n/** Index of the unescaped backtick closing the substitution opened at {@link openIndex}. */\nfunction findClosingBacktick(text: string, openIndex: number): number {\n\tfor (let index = openIndex + 1; index < text.length; index += 1) {\n\t\tif (text[index] === \"\\\\\") {\n\t\t\tindex += 1;\n\t\t\tcontinue;\n\t\t}\n\t\tif (text[index] === \"`\") return index;\n\t}\n\treturn -1;\n}\n\n/**\n * Collect the command strings bash executes via command/process substitution:\n * `$(cmd)`, backticks, `<(cmd)`, and `>(cmd)`. Single-quoted regions never\n * expand and are skipped; double-quoted regions expand, so `$()`/backticks\n * inside them are collected. Bodies are recursed into for nested\n * substitutions up to {@link MAX_SUBSTITUTION_DEPTH}.\n */\nexport function extractCommandSubstitutions(command: string, depth = 0): string[] {\n\tif (depth >= MAX_SUBSTITUTION_DEPTH) return [];\n\tconst bodies: string[] = [];\n\tlet quote: \"'\" | '\"' | undefined;\n\tfor (let index = 0; index < command.length; index += 1) {\n\t\tconst character = command[index];\n\t\tif (quote === \"'\") {\n\t\t\t// No expansion of any kind happens inside single quotes.\n\t\t\tif (character === \"'\") quote = undefined;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"\\\\\") {\n\t\t\tindex += 1;\n\t\t\tcontinue;\n\t\t}\n\t\tif (!quote && (character === \"'\" || character === '\"')) {\n\t\t\tquote = character;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"$\" && command[index + 1] === \"(\") {\n\t\t\tconst close = findMatchingParen(command, index + 1);\n\t\t\tif (close === -1) break;\n\t\t\tconst body = command.slice(index + 2, close);\n\t\t\tbodies.push(body);\n\t\t\tbodies.push(...extractCommandSubstitutions(body, depth + 1));\n\t\t\tindex = close;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"`\") {\n\t\t\tconst close = findClosingBacktick(command, index);\n\t\t\tif (close === -1) break;\n\t\t\tconst body = command.slice(index + 1, close);\n\t\t\tbodies.push(body);\n\t\t\tbodies.push(...extractCommandSubstitutions(body, depth + 1));\n\t\t\tindex = close;\n\t\t\tcontinue;\n\t\t}\n\t\t// Process substitution is only valid unquoted.\n\t\tif (!quote && (character === \"<\" || character === \">\") && command[index + 1] === \"(\") {\n\t\t\tconst close = findMatchingParen(command, index + 1);\n\t\t\tif (close === -1) break;\n\t\t\tconst body = command.slice(index + 2, close);\n\t\t\tbodies.push(body);\n\t\t\tbodies.push(...extractCommandSubstitutions(body, depth + 1));\n\t\t\tindex = close;\n\t\t}\n\t}\n\treturn bodies;\n}\n\n/**\n * Classify every command-substitution body embedded in {@link command}.\n * Bash executes these regardless of how benign the surrounding command looks,\n * so `echo $(rm -rf ~)` must not sail through as allow-tier.\n */\nfunction classifySubstitutionBodies(command: string, depth: number): CommandVerdict | null {\n\tif (depth >= MAX_WRAP_DEPTH) return null;\n\tlet worst: CommandVerdict | null = null;\n\tfor (const body of extractCommandSubstitutions(command)) {\n\t\tconst bodyVerdict = classifyShellCommandInternal(body, depth + 1);\n\t\tif (!worst || RISK_RANK[bodyVerdict.risk] > RISK_RANK[worst.risk]) worst = bodyVerdict;\n\t}\n\treturn worst;\n}\n\nfunction verdict(risk: CommandRisk, rule: string, reason: string): CommandVerdict {\n\treturn { risk, rule, reason };\n}\n\nfunction allowVerdict(): CommandVerdict {\n\treturn verdict(\"allow\", \"command.allow\", \"No destructive or protected command pattern matched.\");\n}\n\nfunction isEnvAssignment(token: string | undefined): boolean {\n\treturn token !== undefined && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);\n}\n\nfunction tokenizeShellSegment(segment: string): string[] {\n\tconst tokens: string[] = [];\n\tlet current = \"\";\n\tlet quote: string | undefined;\n\tlet escaped = false;\n\n\tfor (const character of segment.trim()) {\n\t\tif (escaped) {\n\t\t\tcurrent += character;\n\t\t\tescaped = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"\\\\\" && quote !== \"'\") {\n\t\t\tescaped = true;\n\t\t\tcontinue;\n\t\t}\n\t\tif (quote) {\n\t\t\tif (character === quote) {\n\t\t\t\tquote = undefined;\n\t\t\t} else {\n\t\t\t\tcurrent += character;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"'\" || character === '\"') {\n\t\t\tquote = character;\n\t\t\tcontinue;\n\t\t}\n\t\tif (/\\s/.test(character)) {\n\t\t\tif (current) {\n\t\t\t\ttokens.push(current);\n\t\t\t\tcurrent = \"\";\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tcurrent += character;\n\t}\n\n\tif (escaped) current += \"\\\\\";\n\tif (current) tokens.push(current);\n\treturn tokens;\n}\n\nfunction splitShellCommands(command: string): string[] {\n\tconst segments: string[] = [];\n\tlet current = \"\";\n\tlet quote: string | undefined;\n\tlet escaped = false;\n\n\tfor (let index = 0; index < command.length; index += 1) {\n\t\tconst character = command[index];\n\t\tconst next = command[index + 1];\n\t\tif (escaped) {\n\t\t\tcurrent += character;\n\t\t\tescaped = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"\\\\\" && quote !== \"'\") {\n\t\t\tescaped = true;\n\t\t\tcurrent += character;\n\t\t\tcontinue;\n\t\t}\n\t\tif (quote) {\n\t\t\tcurrent += character;\n\t\t\tif (character === quote) quote = undefined;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"'\" || character === '\"') {\n\t\t\tquote = character;\n\t\t\tcurrent += character;\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \";\" || character === \"|\" || character === \"\\n\" || character === \"\\r\") {\n\t\t\tif (current.trim()) segments.push(current.trim());\n\t\t\tcurrent = \"\";\n\t\t\tcontinue;\n\t\t}\n\t\tif (character === \"&\") {\n\t\t\tif (current.trim()) segments.push(current.trim());\n\t\t\tcurrent = \"\";\n\t\t\tif (next === \"&\") index += 1;\n\t\t\tcontinue;\n\t\t}\n\t\tcurrent += character;\n\t}\n\n\tif (current.trim()) segments.push(current.trim());\n\treturn segments;\n}\n\nfunction skipEnvironmentPrefix(tokens: readonly string[], startIndex: number): number {\n\tlet index = startIndex;\n\twhile (isEnvAssignment(tokens[index])) index += 1;\n\tif (tokens[index]?.toLowerCase() !== \"env\") return index;\n\n\tindex += 1;\n\twhile (index < tokens.length) {\n\t\tconst token = tokens[index];\n\t\tif (isEnvAssignment(token)) {\n\t\t\tindex += 1;\n\t\t\tcontinue;\n\t\t}\n\t\tif (token?.startsWith(\"-\")) {\n\t\t\tindex += 1;\n\t\t\tcontinue;\n\t\t}\n\t\tbreak;\n\t}\n\treturn index;\n}\n\nfunction optionConsumesValue(option: string, optionsWithValue: ReadonlySet<string>): boolean {\n\tif (option.includes(\"=\")) return false;\n\treturn optionsWithValue.has(option);\n}\n\nfunction skipPrivilegeOptions(tokens: readonly string[], startIndex: number, privilege: string): number {\n\tif (privilege !== \"sudo\" && privilege !== \"doas\") return startIndex;\n\n\tlet index = startIndex;\n\twhile (index < tokens.length) {\n\t\tconst token = tokens[index];\n\t\tif (!token?.startsWith(\"-\")) break;\n\t\tindex += 1;\n\t\tif (token === \"--\") break;\n\t\tif (optionConsumesValue(token, SUDO_OPTIONS_WITH_VALUE)) index += 1;\n\t}\n\treturn index;\n}\n\nfunction stripCommandPrefixes(tokens: readonly string[]): CommandPrefixResult {\n\tlet index = skipEnvironmentPrefix(tokens, 0);\n\tconst privilege = tokens[index]?.toLowerCase();\n\tif (!PRIVILEGE_COMMANDS.has(privilege ?? \"\")) {\n\t\treturn { tokens: tokens.slice(index), privilege: undefined };\n\t}\n\n\tindex += 1;\n\tindex = skipPrivilegeOptions(tokens, index, privilege ?? \"\");\n\tindex = skipEnvironmentPrefix(tokens, index);\n\treturn { tokens: tokens.slice(index), privilege };\n}\n\nfunction hasForkBomb(command: string): boolean {\n\treturn command.replace(/\\s/g, \"\").includes(\":(){:|:&};:\");\n}\n\nfunction normalizePathTarget(target: string): string {\n\treturn target.replace(/\\/+$/g, (match) => (target === match ? \"/\" : \"\")).replace(/\\/+/g, \"/\");\n}\n\nfunction classifyRmTarget(target: string): CommandVerdict | null {\n\tconst normalized = normalizePathTarget(target);\n\tif (normalized === \"/\" || normalized === \"/*\" || normalized === \"/.*\") {\n\t\treturn verdict(\"block\", \"fs.rm_rf_root\", \"Recursive forced rm targets filesystem root.\");\n\t}\n\tif (normalized === \"~\" || normalized === \"~/\" || normalized === \"~/*\" || normalized === \"~/.*\") {\n\t\treturn verdict(\"block\", \"fs.rm_rf_home\", \"Recursive forced rm targets the home directory.\");\n\t}\n\t// $HOME / ${HOME} expands to the user home directory at runtime.\n\tconst lower = normalized.toLowerCase();\n\tif (HOME_VARIABLE_RM_TARGETS.has(lower)) {\n\t\treturn verdict(\n\t\t\t\"confirm\",\n\t\t\t\"fs.rm_rf_home_var\",\n\t\t\t\"Recursive forced rm targets $HOME, which expands to the user home directory.\",\n\t\t);\n\t}\n\t// ~username targets another user home directory.\n\tif (/^~[A-Za-z0-9._-]/.test(normalized)) {\n\t\treturn verdict(\"confirm\", \"fs.rm_rf_user_home\", \"Recursive forced rm targets another user home directory.\");\n\t}\n\t// \".\" or \"./\" — current working directory (project root deletion risk).\n\tif (normalized === \".\") {\n\t\treturn verdict(\"confirm\", \"fs.rm_rf_cwd\", \"Recursive forced rm targets the current working directory.\");\n\t}\n\t// \"..\" — parent directory.\n\tif (normalized === \"..\") {\n\t\treturn verdict(\"confirm\", \"fs.rm_rf_parent\", \"Recursive forced rm targets the parent directory.\");\n\t}\n\treturn null;\n}\n\nfunction classifyRm(tokens: readonly string[]): CommandVerdict | null {\n\tlet hasRecursive = false;\n\tlet hasForce = false;\n\tlet endOfOptions = false;\n\tconst targets: string[] = [];\n\n\tfor (const token of tokens.slice(1)) {\n\t\tif (!endOfOptions && token === \"--\") {\n\t\t\tendOfOptions = true;\n\t\t\tcontinue;\n\t\t}\n\t\tif (!endOfOptions && token.startsWith(\"-\") && token !== \"-\") {\n\t\t\tif (token === \"--recursive\") hasRecursive = true;\n\t\t\tif (token === \"--force\") hasForce = true;\n\t\t\tif (!token.startsWith(\"--\")) {\n\t\t\t\tconst flagCharacters = token.slice(1).split(\"\");\n\t\t\t\tif (flagCharacters.some((flag) => flag === \"r\" || flag === \"R\")) hasRecursive = true;\n\t\t\t\tif (flagCharacters.includes(\"f\")) hasForce = true;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\ttargets.push(token);\n\t}\n\n\tif (!hasRecursive || !hasForce) return null;\n\tfor (const target of targets) {\n\t\tconst targetVerdict = classifyRmTarget(target);\n\t\tif (targetVerdict) return targetVerdict;\n\t}\n\treturn null;\n}\n\nfunction isBlockDeviceOutput(token: string): boolean {\n\treturn /^of=\\/dev\\/(?:sd[a-z][a-z0-9]*|nvme[0-9a-z]+|disk(?:[0-9].*|\\/.*|$))/i.test(token);\n}\n\nfunction classifyDestructiveFilesystemCommand(command: string): CommandVerdict | null {\n\tif (hasForkBomb(command)) {\n\t\treturn verdict(\"block\", \"process.fork_bomb\", \"Shell fork bomb pattern would exhaust process resources.\");\n\t}\n\n\tconst { tokens } = stripCommandPrefixes(tokenizeShellSegment(command));\n\tconst executable = tokens[0]?.toLowerCase();\n\tif (!executable) return null;\n\tif (executable === \"rm\") return classifyRm(tokens);\n\tif (executable.startsWith(\"mkfs\")) {\n\t\treturn verdict(\"block\", \"fs.mkfs\", \"Filesystem formatting commands are blocked.\");\n\t}\n\tif (executable === \"dd\" && tokens.slice(1).some(isBlockDeviceOutput)) {\n\t\treturn verdict(\"block\", \"fs.dd_block_device\", \"dd writes directly to a block-device output path.\");\n\t}\n\treturn null;\n}\n\nfunction findGitCommand(tokens: readonly string[]): { command: string; args: string[]; originalArgs: string[] } | null {\n\tif (tokens[0]?.toLowerCase() !== \"git\") return null;\n\tlet index = 1;\n\twhile (index < tokens.length && tokens[index]?.startsWith(\"-\")) {\n\t\tconst option = tokens[index];\n\t\tconst normalizedOption = option.toLowerCase();\n\t\tindex += 1;\n\t\tif (option === \"--\") break;\n\t\tif (\n\t\t\toptionConsumesValue(option, GIT_GLOBAL_OPTIONS_WITH_VALUE) ||\n\t\t\toptionConsumesValue(normalizedOption, GIT_GLOBAL_OPTIONS_WITH_VALUE)\n\t\t) {\n\t\t\tindex += 1;\n\t\t}\n\t}\n\tconst command = tokens[index]?.toLowerCase();\n\tif (!command) return null;\n\tconst originalArgs = tokens.slice(index + 1);\n\treturn {\n\t\tcommand,\n\t\targs: originalArgs.map((argument) => argument.toLowerCase()),\n\t\toriginalArgs,\n\t};\n}\n\nfunction hasGitCleanForceDirectory(args: readonly string[]): boolean {\n\tlet hasForce = false;\n\tlet hasDirectory = false;\n\tfor (const arg of args) {\n\t\tif (arg === \"--force\") hasForce = true;\n\t\tif (arg === \"-f\") hasForce = true;\n\t\tif (arg === \"-d\") hasDirectory = true;\n\t\tif (arg.startsWith(\"-\") && !arg.startsWith(\"--\")) {\n\t\t\tconst flags = arg.slice(1).split(\"\");\n\t\t\tif (flags.includes(\"f\")) hasForce = true;\n\t\t\tif (flags.includes(\"d\")) hasDirectory = true;\n\t\t}\n\t}\n\treturn hasForce && hasDirectory;\n}\n\nfunction classifyProtectedGitCommand(command: string): CommandVerdict | null {\n\tconst { tokens } = stripCommandPrefixes(tokenizeShellSegment(command));\n\tconst gitCommand = findGitCommand(tokens);\n\tif (!gitCommand) return null;\n\n\tif (gitCommand.command === \"reset\" && gitCommand.args.includes(\"--hard\")) {\n\t\treturn verdict(\"confirm\", \"git.reset_hard\", \"git reset --hard can discard worktree changes.\");\n\t}\n\tif (gitCommand.command === \"restore\") {\n\t\tconst targetAll = gitCommand.originalArgs.includes(\".\") || gitCommand.originalArgs.includes(\"*\");\n\t\tconst rewritesWorktree = gitCommand.args.includes(\"--worktree\") || gitCommand.args.includes(\"--source\");\n\t\tif (targetAll || rewritesWorktree) {\n\t\t\treturn verdict(\"confirm\", \"git.restore\", \"git restore can discard or overwrite local worktree changes.\");\n\t\t}\n\t}\n\tif (gitCommand.command === \"checkout\" && gitCommand.originalArgs.includes(\".\")) {\n\t\treturn verdict(\"confirm\", \"git.checkout_dot\", \"git checkout . can overwrite local worktree changes.\");\n\t}\n\tif (gitCommand.command === \"clean\" && hasGitCleanForceDirectory(gitCommand.args)) {\n\t\treturn verdict(\"confirm\", \"git.clean_force\", \"git clean -fd can delete untracked files and directories.\");\n\t}\n\tif (gitCommand.command === \"stash\" && gitCommand.originalArgs.length === 0) {\n\t\treturn verdict(\"confirm\", \"git.stash_bare\", \"Bare git stash can hide local worktree changes.\");\n\t}\n\tif (gitCommand.command === \"commit\" && gitCommand.args.includes(\"--no-verify\")) {\n\t\treturn verdict(\"confirm\", \"git.no_verify\", \"git commit --no-verify bypasses repository verification hooks.\");\n\t}\n\tif (gitCommand.command === \"push\" && gitCommand.args.includes(\"--no-verify\")) {\n\t\treturn verdict(\"confirm\", \"git.push_no_verify\", \"git push --no-verify bypasses remote verification hooks.\");\n\t}\n\tif (\n\t\tgitCommand.command === \"push\" &&\n\t\t(gitCommand.originalArgs.includes(\"-f\") ||\n\t\t\tgitCommand.args.includes(\"--force\") ||\n\t\t\tgitCommand.args.includes(\"--force-with-lease\"))\n\t) {\n\t\treturn verdict(\"confirm\", \"git.force_push\", \"Force-pushing can rewrite remote history.\");\n\t}\n\treturn null;\n}\n\nfunction classifyPrivilegeCommand(command: string): CommandVerdict | null {\n\tconst tokens = tokenizeShellSegment(command);\n\tconst index = skipEnvironmentPrefix(tokens, 0);\n\tconst privilege = tokens[index]?.toLowerCase();\n\tif (!PRIVILEGE_COMMANDS.has(privilege ?? \"\")) return null;\n\treturn verdict(\"confirm\", `priv.${privilege}`, `${privilege} requires explicit per-command confirmation.`);\n}\n\n/**\n * Locate the inline command-string argument for a `shell -c \"<cmd>\"` invocation.\n * Returns the token index of the command string, or -1 when the shell runs a\n * script file (no inline command to classify).\n */\nfunction findShellCommandStringIndex(tokens: readonly string[]): number {\n\tfor (let index = 1; index < tokens.length; index += 1) {\n\t\tconst token = tokens[index];\n\t\tif (!token.startsWith(\"-\")) return -1;\n\t\tif (/^-[a-z]*c$/i.test(token)) {\n\t\t\treturn index + 1 < tokens.length ? index + 1 : -1;\n\t\t}\n\t}\n\treturn -1;\n}\n\n/**\n * Locate the inline program-string argument for an interpreter invocation\n * (`python -c`, `node -e`, `perl -e`, ...). Returns the token index of the\n * program string, or -1 when the interpreter runs a script file (no inline\n * program to classify).\n */\nfunction findInterpreterInlineIndex(tokens: readonly string[], flags: readonly string[]): number {\n\tfor (let index = 1; index < tokens.length; index += 1) {\n\t\tconst token = tokens[index];\n\t\tif (token === \"--\") return -1;\n\t\tif (!token.startsWith(\"-\")) return -1;\n\t\tif (flags.includes(token) || flags.includes(token.toLowerCase())) {\n\t\t\treturn index + 1 < tokens.length ? index + 1 : -1;\n\t\t}\n\t}\n\treturn -1;\n}\n\n/** Drop leading xargs options (and their values) to expose the wrapped command. */\nfunction stripXargsOptions(args: readonly string[]): string[] {\n\tlet index = 0;\n\twhile (index < args.length) {\n\t\tconst token = args[index];\n\t\tif (!token.startsWith(\"-\") || token === \"-\") break;\n\t\tindex += 1;\n\t\tif (token === \"--\") break;\n\t\tif (!token.includes(\"=\") && XARGS_OPTIONS_WITH_VALUE.has(token)) index += 1;\n\t}\n\treturn args.slice(index);\n}\n\n/** Extract the command tokens of a `find ... -exec <cmd> {} ;|+` clause, if present. */\nfunction extractFindExec(tokens: readonly string[]): string[] | null {\n\tfor (let index = 1; index < tokens.length; index += 1) {\n\t\tconst token = tokens[index].toLowerCase();\n\t\tif (token === \"-exec\" || token === \"-execdir\" || token === \"-ok\" || token === \"-okdir\") {\n\t\t\tconst collected: string[] = [];\n\t\t\tfor (let inner = index + 1; inner < tokens.length; inner += 1) {\n\t\t\t\tconst piece = tokens[inner];\n\t\t\t\tif (piece === \";\" || piece === \"+\") break;\n\t\t\t\tif (piece === \"{}\") continue;\n\t\t\t\tcollected.push(piece);\n\t\t\t}\n\t\t\treturn collected.length > 0 ? collected : null;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Classify the effective command hidden inside a wrapper executable so a\n * destructive or protected command cannot be smuggled past the classifier via\n * `bash -c`, `eval`, `xargs`, or `find -exec`. Returns null when the segment is\n * not a recognized wrapper.\n */\nfunction classifyWrappedCommand(command: string, depth: number): CommandVerdict | null {\n\tif (depth >= MAX_WRAP_DEPTH) return null;\n\tconst { tokens } = stripCommandPrefixes(tokenizeShellSegment(command));\n\tconst executable = tokens[0]?.toLowerCase();\n\tif (!executable) return null;\n\n\tif (SHELL_WRAPPERS.has(executable)) {\n\t\tconst innerIndex = findShellCommandStringIndex(tokens);\n\t\tconst inner = innerIndex === -1 ? undefined : tokens[innerIndex];\n\t\treturn inner ? classifyShellCommandInternal(inner, depth + 1) : null;\n\t}\n\tif (executable === \"eval\") {\n\t\tconst inner = tokens.slice(1).join(\" \").trim();\n\t\treturn inner ? classifyShellCommandInternal(inner, depth + 1) : null;\n\t}\n\tif (executable === \"xargs\") {\n\t\tconst inner = stripXargsOptions(tokens.slice(1)).join(\" \").trim();\n\t\treturn inner ? classifyShellCommandInternal(inner, depth + 1) : null;\n\t}\n\tif (executable === \"find\") {\n\t\tconst lowered = tokens.map((token) => token.toLowerCase());\n\t\tconst hasDelete = lowered.includes(\"-delete\");\n\t\tconst execTokens = extractFindExec(tokens);\n\t\tconst inner = execTokens?.join(\" \").trim();\n\t\tconst innerVerdict = inner ? classifyShellCommandInternal(inner, depth + 1) : null;\n\t\tif (hasDelete) {\n\t\t\tconst deleteVerdict = verdict(\n\t\t\t\t\"confirm\",\n\t\t\t\t\"fs.find_delete\",\n\t\t\t\t\"find -delete removes matched files and directories.\",\n\t\t\t);\n\t\t\tif (!innerVerdict || RISK_RANK[deleteVerdict.risk] > RISK_RANK[innerVerdict.risk]) {\n\t\t\t\treturn deleteVerdict;\n\t\t\t}\n\t\t}\n\t\treturn innerVerdict ?? null;\n\t}\n\tconst inlineFlags = INTERPRETER_INLINE_FLAGS[executable];\n\tif (inlineFlags) {\n\t\tconst innerIndex = findInterpreterInlineIndex(tokens, inlineFlags);\n\t\tif (innerIndex === -1) return null; // script file path — allow\n\t\tconst program = tokens[innerIndex];\n\t\tif (INLINE_DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(program))) {\n\t\t\treturn verdict(\n\t\t\t\t\"confirm\",\n\t\t\t\t`interp.${executable}_inline_destructive`,\n\t\t\t\t`${executable} inline program can execute destructive shell commands.`,\n\t\t\t);\n\t\t}\n\t\treturn null;\n\t}\n\treturn null;\n}\n\nfunction classifySingleCommand(command: string, depth: number): CommandVerdict {\n\tconst trimmed = command.trim();\n\tif (!trimmed) return allowVerdict();\n\n\t// Merge every signal by risk rank instead of returning on the first hit so a\n\t// dangerous substitution body cannot hide behind a benign-looking or merely\n\t// confirm-tier surrounding command (e.g. `git stash $(mkfs.ext4 /dev/sda)`).\n\tlet selected: CommandVerdict | undefined;\n\tconst consider = (candidate: CommandVerdict | null) => {\n\t\tif (candidate && (!selected || RISK_RANK[candidate.risk] > RISK_RANK[selected.risk])) {\n\t\t\tselected = candidate;\n\t\t}\n\t};\n\n\tconsider(classifyDestructiveFilesystemCommand(trimmed));\n\n\tconst wrapped = classifyWrappedCommand(trimmed, depth);\n\tconsider(wrapped?.risk === \"block\" ? wrapped : null);\n\n\tconsider(classifyProtectedGitCommand(trimmed));\n\tconsider(classifyPrivilegeCommand(trimmed));\n\n\tconsider(wrapped && RISK_RANK[wrapped.risk] > RISK_RANK.allow ? wrapped : null);\n\tconsider(classifySubstitutionBodies(trimmed, depth));\n\n\treturn selected ?? allowVerdict();\n}\n\nfunction classifyShellCommandInternal(command: string, depth: number): CommandVerdict {\n\tlet selected = classifySingleCommand(command, depth);\n\tfor (const segment of splitShellCommands(command)) {\n\t\tconst candidate = classifySingleCommand(segment, depth);\n\t\tif (RISK_RANK[candidate.risk] > RISK_RANK[selected.risk]) selected = candidate;\n\t\tif (selected.risk === \"block\") break;\n\t}\n\treturn selected;\n}\n\nexport function classifyShellCommand(command: string): CommandVerdict {\n\treturn classifyShellCommandInternal(command, 0);\n}\n\nexport function isDestructiveFilesystem(command: string): boolean {\n\treturn classifyDestructiveFilesystemCommand(command) !== null;\n}\n\nexport function isProtectedGitOperation(command: string): boolean {\n\treturn classifyProtectedGitCommand(command) !== null;\n}\n\nexport function isPrivilegeEscalation(command: string): boolean {\n\treturn classifyPrivilegeCommand(command) !== null;\n}\n"]}
@@ -21,6 +21,8 @@ const SUDO_OPTIONS_WITH_VALUE = new Set([
21
21
  const GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace"]);
22
22
  /** Maximum recursion depth when unwrapping shell wrappers (bash -c, eval, xargs, find -exec). */
23
23
  const MAX_WRAP_DEPTH = 6;
24
+ /** Maximum recursion depth when extracting nested command substitutions ($(), backticks). */
25
+ const MAX_SUBSTITUTION_DEPTH = 6;
24
26
  /** Executables that run an inline command string passed via a `-c`-style flag. */
25
27
  const SHELL_WRAPPERS = new Set(["bash", "sh", "zsh", "dash", "ksh", "ash"]);
26
28
  /**
@@ -78,6 +80,128 @@ const XARGS_OPTIONS_WITH_VALUE = new Set([
78
80
  "--replace",
79
81
  ]);
80
82
  const HOME_VARIABLE_RM_TARGETS = new Set(["$home", "$" + "{home}", "$home/", "$" + "{home}/"]);
83
+ /**
84
+ * Index of the parenthesis matching the one at {@link openIndex}, honoring
85
+ * quotes and backslash escapes so a `)` inside quotes does not terminate the
86
+ * substitution body. Returns -1 when unmatched.
87
+ */
88
+ function findMatchingParen(text, openIndex) {
89
+ let depth = 0;
90
+ let quote;
91
+ for (let index = openIndex; index < text.length; index += 1) {
92
+ const character = text[index];
93
+ if (character === "\\") {
94
+ index += 1;
95
+ continue;
96
+ }
97
+ if (quote) {
98
+ if (character === quote)
99
+ quote = undefined;
100
+ continue;
101
+ }
102
+ if (character === "'" || character === '"') {
103
+ quote = character;
104
+ continue;
105
+ }
106
+ if (character === "(")
107
+ depth += 1;
108
+ else if (character === ")") {
109
+ depth -= 1;
110
+ if (depth === 0)
111
+ return index;
112
+ }
113
+ }
114
+ return -1;
115
+ }
116
+ /** Index of the unescaped backtick closing the substitution opened at {@link openIndex}. */
117
+ function findClosingBacktick(text, openIndex) {
118
+ for (let index = openIndex + 1; index < text.length; index += 1) {
119
+ if (text[index] === "\\") {
120
+ index += 1;
121
+ continue;
122
+ }
123
+ if (text[index] === "`")
124
+ return index;
125
+ }
126
+ return -1;
127
+ }
128
+ /**
129
+ * Collect the command strings bash executes via command/process substitution:
130
+ * `$(cmd)`, backticks, `<(cmd)`, and `>(cmd)`. Single-quoted regions never
131
+ * expand and are skipped; double-quoted regions expand, so `$()`/backticks
132
+ * inside them are collected. Bodies are recursed into for nested
133
+ * substitutions up to {@link MAX_SUBSTITUTION_DEPTH}.
134
+ */
135
+ export function extractCommandSubstitutions(command, depth = 0) {
136
+ if (depth >= MAX_SUBSTITUTION_DEPTH)
137
+ return [];
138
+ const bodies = [];
139
+ let quote;
140
+ for (let index = 0; index < command.length; index += 1) {
141
+ const character = command[index];
142
+ if (quote === "'") {
143
+ // No expansion of any kind happens inside single quotes.
144
+ if (character === "'")
145
+ quote = undefined;
146
+ continue;
147
+ }
148
+ if (character === "\\") {
149
+ index += 1;
150
+ continue;
151
+ }
152
+ if (!quote && (character === "'" || character === '"')) {
153
+ quote = character;
154
+ continue;
155
+ }
156
+ if (character === "$" && command[index + 1] === "(") {
157
+ const close = findMatchingParen(command, index + 1);
158
+ if (close === -1)
159
+ break;
160
+ const body = command.slice(index + 2, close);
161
+ bodies.push(body);
162
+ bodies.push(...extractCommandSubstitutions(body, depth + 1));
163
+ index = close;
164
+ continue;
165
+ }
166
+ if (character === "`") {
167
+ const close = findClosingBacktick(command, index);
168
+ if (close === -1)
169
+ break;
170
+ const body = command.slice(index + 1, close);
171
+ bodies.push(body);
172
+ bodies.push(...extractCommandSubstitutions(body, depth + 1));
173
+ index = close;
174
+ continue;
175
+ }
176
+ // Process substitution is only valid unquoted.
177
+ if (!quote && (character === "<" || character === ">") && command[index + 1] === "(") {
178
+ const close = findMatchingParen(command, index + 1);
179
+ if (close === -1)
180
+ break;
181
+ const body = command.slice(index + 2, close);
182
+ bodies.push(body);
183
+ bodies.push(...extractCommandSubstitutions(body, depth + 1));
184
+ index = close;
185
+ }
186
+ }
187
+ return bodies;
188
+ }
189
+ /**
190
+ * Classify every command-substitution body embedded in {@link command}.
191
+ * Bash executes these regardless of how benign the surrounding command looks,
192
+ * so `echo $(rm -rf ~)` must not sail through as allow-tier.
193
+ */
194
+ function classifySubstitutionBodies(command, depth) {
195
+ if (depth >= MAX_WRAP_DEPTH)
196
+ return null;
197
+ let worst = null;
198
+ for (const body of extractCommandSubstitutions(command)) {
199
+ const bodyVerdict = classifyShellCommandInternal(body, depth + 1);
200
+ if (!worst || RISK_RANK[bodyVerdict.risk] > RISK_RANK[worst.risk])
201
+ worst = bodyVerdict;
202
+ }
203
+ return worst;
204
+ }
81
205
  function verdict(risk, rule, reason) {
82
206
  return { risk, rule, reason };
83
207
  }
@@ -538,21 +662,23 @@ function classifySingleCommand(command, depth) {
538
662
  const trimmed = command.trim();
539
663
  if (!trimmed)
540
664
  return allowVerdict();
541
- const destructiveFilesystem = classifyDestructiveFilesystemCommand(trimmed);
542
- if (destructiveFilesystem)
543
- return destructiveFilesystem;
665
+ // Merge every signal by risk rank instead of returning on the first hit so a
666
+ // dangerous substitution body cannot hide behind a benign-looking or merely
667
+ // confirm-tier surrounding command (e.g. `git stash $(mkfs.ext4 /dev/sda)`).
668
+ let selected;
669
+ const consider = (candidate) => {
670
+ if (candidate && (!selected || RISK_RANK[candidate.risk] > RISK_RANK[selected.risk])) {
671
+ selected = candidate;
672
+ }
673
+ };
674
+ consider(classifyDestructiveFilesystemCommand(trimmed));
544
675
  const wrapped = classifyWrappedCommand(trimmed, depth);
545
- if (wrapped && wrapped.risk === "block")
546
- return wrapped;
547
- const protectedGit = classifyProtectedGitCommand(trimmed);
548
- if (protectedGit)
549
- return protectedGit;
550
- const privilege = classifyPrivilegeCommand(trimmed);
551
- if (privilege)
552
- return privilege;
553
- if (wrapped && RISK_RANK[wrapped.risk] > RISK_RANK.allow)
554
- return wrapped;
555
- return allowVerdict();
676
+ consider(wrapped?.risk === "block" ? wrapped : null);
677
+ consider(classifyProtectedGitCommand(trimmed));
678
+ consider(classifyPrivilegeCommand(trimmed));
679
+ consider(wrapped && RISK_RANK[wrapped.risk] > RISK_RANK.allow ? wrapped : null);
680
+ consider(classifySubstitutionBodies(trimmed, depth));
681
+ return selected ?? allowVerdict();
556
682
  }
557
683
  function classifyShellCommandInternal(command, depth) {
558
684
  let selected = classifySingleCommand(command, depth);