auto-mode 0.21.0 → 1.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.
@@ -1,11 +1,51 @@
1
1
  import { t as getEditFields } from "./get-edit-fields-BtAvHIsq.js";
2
+ import { createHash, randomUUID } from "node:crypto";
2
3
  import { appendFile, link, lstat, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
3
4
  import { basename, dirname, isAbsolute, join, matchesGlob, normalize, parse, relative, resolve } from "node:path";
5
+ import { setTimeout } from "node:timers/promises";
4
6
  import { spawn } from "node:child_process";
5
7
  import { homedir, tmpdir } from "node:os";
6
- import { createHash, randomUUID } from "node:crypto";
7
- import { setTimeout } from "node:timers/promises";
8
8
  import { existsSync } from "node:fs";
9
+ //#region mods/auto-mode/contract/find-current-direct-user-message.ts
10
+ function findCurrentDirectUserMessage(message) {
11
+ return message === null || message === void 0 || message.freshness === "stale" ? null : message.text;
12
+ }
13
+ //#endregion
14
+ //#region src/budget/build-retry-key.ts
15
+ const DESCRIPTIVE_FIELDS = { Bash: [
16
+ "description",
17
+ "timeout",
18
+ "run_in_background"
19
+ ] };
20
+ function buildRetryKey(request) {
21
+ const ignored = Object.hasOwn(DESCRIPTIVE_FIELDS, request.toolName) ? DESCRIPTIVE_FIELDS[request.toolName] ?? [] : [];
22
+ const input = Object.fromEntries(Object.entries(request.toolInput).filter(([key]) => !ignored.includes(key)));
23
+ const message = request.decisionContext?.lastDirectUserMessage ?? null;
24
+ const canonical = JSON.stringify(sortKeys({
25
+ cwd: request.cwd,
26
+ toolName: request.toolName,
27
+ toolInput: input,
28
+ lastDirectUserMessage: message?.text ?? null,
29
+ ...message?.freshness === "stale" ? { lastDirectUserMessageFreshness: "stale" } : {}
30
+ }));
31
+ return createHash("sha256").update(canonical).digest("hex");
32
+ }
33
+ function sortKeys(value) {
34
+ if (Array.isArray(value)) return value.map((item) => sortKeys(item));
35
+ if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).toSorted(([a], [b]) => a < b ? -1 : 1).map(([key, item]) => [key, sortKeys(item)]));
36
+ return value;
37
+ }
38
+ //#endregion
39
+ //#region src/budget/find-retry-deny.ts
40
+ function findRetryDeny(state, retryKey) {
41
+ if (state.lastDenied === null || state.lastDenied.retryKey !== retryKey) return null;
42
+ return {
43
+ kind: "deny",
44
+ rule: state.lastDenied.rule,
45
+ reason: state.lastDenied.reason
46
+ };
47
+ }
48
+ //#endregion
9
49
  //#region node_modules/.bun/zod@4.6.2/node_modules/zod/v4/core/util.js
10
50
  function getEnumValues(entries) {
11
51
  const numericValues = Object.values(entries).filter((v) => typeof v === "number");
@@ -5530,6 +5570,196 @@ function superRefine(fn, params) {
5530
5570
  return /* @__PURE__ */ _superRefine(fn, params);
5531
5571
  }
5532
5572
  //#endregion
5573
+ //#region src/state/load-json-file.ts
5574
+ async function loadJSONFile(path, schema, fallback) {
5575
+ try {
5576
+ const text = await readFile(path, "utf8");
5577
+ const parsed = schema.safeParse(JSON.parse(text));
5578
+ return parsed.success ? parsed.data : fallback;
5579
+ } catch {
5580
+ return fallback;
5581
+ }
5582
+ }
5583
+ //#endregion
5584
+ //#region src/budget/types.ts
5585
+ const EMPTY_DENIAL_STATE = {
5586
+ consecutive: 0,
5587
+ session: 0,
5588
+ lastDenied: null
5589
+ };
5590
+ //#endregion
5591
+ //#region src/budget/load-denial-state.ts
5592
+ const count = number().int().nonnegative();
5593
+ const stateSchema$1 = strictObject({
5594
+ consecutive: count,
5595
+ session: count,
5596
+ lastDenied: strictObject({
5597
+ retryKey: string().min(1),
5598
+ rule: string(),
5599
+ reason: string()
5600
+ }).nullable()
5601
+ });
5602
+ function loadDenialState(path) {
5603
+ return loadJSONFile(path, stateSchema$1, EMPTY_DENIAL_STATE);
5604
+ }
5605
+ //#endregion
5606
+ //#region src/budget/plan-denial-budget.ts
5607
+ function planDenialBudget(state, verdict, retryKey, budget) {
5608
+ if (verdict === null) return {
5609
+ verdict: null,
5610
+ state,
5611
+ escalation: false
5612
+ };
5613
+ if (verdict.kind === "allow") return {
5614
+ verdict,
5615
+ state: {
5616
+ ...state,
5617
+ consecutive: 0,
5618
+ lastDenied: null
5619
+ },
5620
+ escalation: false
5621
+ };
5622
+ if (state.consecutive >= budget.consecutive || state.session >= budget.perSession) return {
5623
+ verdict: null,
5624
+ state: EMPTY_DENIAL_STATE,
5625
+ escalation: true
5626
+ };
5627
+ const next = {
5628
+ consecutive: state.consecutive + 1,
5629
+ session: state.session + 1,
5630
+ lastDenied: {
5631
+ retryKey,
5632
+ rule: verdict.rule,
5633
+ reason: verdict.reason
5634
+ }
5635
+ };
5636
+ const left = Math.min(budget.consecutive - next.consecutive, budget.perSession - next.session);
5637
+ return {
5638
+ verdict: {
5639
+ ...verdict,
5640
+ reason: `${verdict.reason} ${formatDenialsLeft(left)}`
5641
+ },
5642
+ state: next,
5643
+ escalation: false
5644
+ };
5645
+ }
5646
+ function formatDenialsLeft(left) {
5647
+ if (left > 0) return `Denials left before auto-mode asks the user: ${left}.`;
5648
+ return "This is the last denial before auto-mode asks the user. Stop now, without finishing the rest of the task, and tell the user what consent you need to continue.";
5649
+ }
5650
+ //#endregion
5651
+ //#region src/budget/resolve-denial-state-path.ts
5652
+ function resolveDenialStatePath(request, stateDir) {
5653
+ const agentID = request.decisionContext?.agentID ?? "";
5654
+ const key = createHash("sha256").update(`${request.sessionID}\0${agentID}`).digest("hex").slice(0, 32);
5655
+ return join(stateDir, "denials", `${key}.json`);
5656
+ }
5657
+ //#endregion
5658
+ //#region src/state/update-json-file.ts
5659
+ const SYSTEM_CLOCK = {
5660
+ now: Date.now,
5661
+ wait: (ms) => setTimeout(ms)
5662
+ };
5663
+ async function updateJSONFile(path, load, update, clock = SYSTEM_CLOCK) {
5664
+ await mkdir(dirname(path), {
5665
+ recursive: true,
5666
+ mode: 448
5667
+ });
5668
+ const lock = `${path}.lock`;
5669
+ const owner = await claimLock(lock, clock);
5670
+ try {
5671
+ const previous = await load(path);
5672
+ const next = update(previous);
5673
+ if (next !== previous) {
5674
+ const staged = `${path}.${randomUUID()}.tmp`;
5675
+ await writeFile(staged, `${JSON.stringify(next)}\n`, { mode: 384 });
5676
+ await rename(staged, path);
5677
+ }
5678
+ return {
5679
+ previous,
5680
+ next
5681
+ };
5682
+ } finally {
5683
+ await removeOwnLock(lock, owner);
5684
+ }
5685
+ }
5686
+ const STALE_LOCK_MS = 1e4;
5687
+ const LOCK_WAIT_MS = 3e3;
5688
+ const LOCK_POLL_MS = 10;
5689
+ async function claimLock(lock, clock) {
5690
+ const deadline = clock.now() + LOCK_WAIT_MS;
5691
+ const owner = randomUUID();
5692
+ const staged = `${lock}.${owner}`;
5693
+ await writeFile(staged, owner, {
5694
+ flag: "wx",
5695
+ mode: 384
5696
+ });
5697
+ try {
5698
+ for (;;) {
5699
+ try {
5700
+ await link(staged, lock);
5701
+ return owner;
5702
+ } catch (error) {
5703
+ if (!hasErrorCode(error, "EEXIST")) throw error;
5704
+ }
5705
+ const held = await stat(lock).catch(() => null);
5706
+ if (held !== null && isStale(held, clock)) {
5707
+ if (await claimStaleLock(lock, staged, held, clock)) return owner;
5708
+ }
5709
+ if (clock.now() > deadline) throw new Error("state file lock unavailable");
5710
+ await clock.wait(LOCK_POLL_MS);
5711
+ }
5712
+ } finally {
5713
+ await rm(staged, { force: true });
5714
+ }
5715
+ }
5716
+ function hasErrorCode(error, code) {
5717
+ return error instanceof Error && "code" in error && error.code === code;
5718
+ }
5719
+ function isStale(file, clock) {
5720
+ return clock.now() - file.mtimeMs > STALE_LOCK_MS;
5721
+ }
5722
+ async function claimStaleLock(lock, staged, held, clock) {
5723
+ const prefix = `${lock}.${String(held.ino)}-${String(held.mtimeMs)}`;
5724
+ const generation = await claimTakeoverMarker(prefix, clock);
5725
+ if (generation === null) return false;
5726
+ try {
5727
+ const current = await stat(lock).catch(() => null);
5728
+ if (current?.ino !== held.ino || current.mtimeMs !== held.mtimeMs) return false;
5729
+ await rename(staged, lock);
5730
+ return true;
5731
+ } finally {
5732
+ await Promise.all(Array.from({ length: generation + 1 }, (_, index) => rm(buildTakeoverMarkerPath(prefix, index), { force: true })));
5733
+ }
5734
+ }
5735
+ async function claimTakeoverMarker(prefix, clock) {
5736
+ for (let generation = 0;; generation += 1) {
5737
+ const marker = buildTakeoverMarkerPath(prefix, generation);
5738
+ try {
5739
+ await writeFile(marker, "", {
5740
+ flag: "wx",
5741
+ mode: 384
5742
+ });
5743
+ return generation;
5744
+ } catch (error) {
5745
+ if (!hasErrorCode(error, "EEXIST")) throw error;
5746
+ }
5747
+ const existing = await stat(marker).catch(() => null);
5748
+ if (existing === null || !isStale(existing, clock)) return null;
5749
+ }
5750
+ }
5751
+ function buildTakeoverMarkerPath(prefix, generation) {
5752
+ return `${prefix}-${String(generation)}.takeover`;
5753
+ }
5754
+ async function removeOwnLock(lock, owner) {
5755
+ if (await readFile(lock, "utf8").catch(() => null) === owner) await rm(lock, { force: true });
5756
+ }
5757
+ //#endregion
5758
+ //#region src/budget/update-denial-state.ts
5759
+ function updateDenialState(path, update, clock) {
5760
+ return updateJSONFile(path, loadDenialState, update, clock);
5761
+ }
5762
+ //#endregion
5533
5763
  //#region src/state/resolve-xdg-dir.ts
5534
5764
  const BASE_DIRS = {
5535
5765
  config: {
@@ -5547,6 +5777,12 @@ function resolveXDGDir(host, kind) {
5547
5777
  const root = base === void 0 || base === "" ? join(host.home, ...dir.fallback) : base;
5548
5778
  return join(root, "auto-mode");
5549
5779
  }
5780
+ const CLI_LIMITS = {
5781
+ keyCommandMs: 5e3,
5782
+ jevMs: 5e3,
5783
+ judgeMs: 12e4,
5784
+ evaluationMs: 265e3
5785
+ };
5550
5786
  //#endregion
5551
5787
  //#region src/config/presets.ts
5552
5788
  const MESSAGES_DEFAULTS = {
@@ -5556,32 +5792,34 @@ const MESSAGES_DEFAULTS = {
5556
5792
  maxTokens: 3e3,
5557
5793
  timeoutMs: 45e3
5558
5794
  };
5795
+ const JEV_PROVIDER = {
5796
+ protocol: "system-one",
5797
+ baseURL: "https://api.typesafe.ai",
5798
+ model: "jev-1.13.0",
5799
+ apiKeyEnv: "TYPESAFE_API_KEY",
5800
+ reasoning: false,
5801
+ maxTokens: 3e3,
5802
+ timeoutMs: CLI_LIMITS.jevMs
5803
+ };
5804
+ const SPARK_PROVIDER = {
5805
+ ...MESSAGES_DEFAULTS,
5806
+ baseURL: "https://api.meta.ai",
5807
+ model: "muse-spark-1.3-contributor",
5808
+ apiKeyEnv: "META_API_KEY",
5809
+ maxTokens: 3e3,
5810
+ timeoutMs: 45e3
5811
+ };
5559
5812
  const PRESETS = {
5560
- jev: {
5561
- protocol: "system-one",
5562
- baseURL: "https://api.typesafe.ai",
5563
- model: "jev-1.13.0",
5564
- apiKeyEnv: "TYPESAFE_API_KEY",
5565
- reasoning: false,
5566
- maxTokens: 3e3,
5567
- timeoutMs: 5e3
5568
- },
5813
+ jev: JEV_PROVIDER,
5569
5814
  "claude-code": {
5570
5815
  protocol: "claude-code",
5571
5816
  baseURL: "https://api.anthropic.com",
5572
5817
  model: "claude-sonnet-5-5",
5573
5818
  reasoning: false,
5574
5819
  maxTokens: 3e3,
5575
- timeoutMs: 12e4
5576
- },
5577
- spark: {
5578
- ...MESSAGES_DEFAULTS,
5579
- baseURL: "https://api.meta.ai",
5580
- model: "muse-spark-1.3-contributor",
5581
- apiKeyEnv: "META_API_KEY",
5582
- maxTokens: 3e3,
5583
- timeoutMs: 45e3
5820
+ timeoutMs: CLI_LIMITS.judgeMs
5584
5821
  },
5822
+ spark: SPARK_PROVIDER,
5585
5823
  claude: {
5586
5824
  ...MESSAGES_DEFAULTS,
5587
5825
  model: "claude-haiku-4-5-20251001",
@@ -5670,7 +5908,7 @@ function toTimerDelay(ms) {
5670
5908
  }
5671
5909
  //#endregion
5672
5910
  //#region src/config/read-api-key-from-command.ts
5673
- const KEY_COMMAND_TIMEOUT_MS = 5e3;
5911
+ const KEY_COMMAND_TIMEOUT_MS = CLI_LIMITS.keyCommandMs;
5674
5912
  const MAX_KEY_OUTPUT_BYTES = 65536;
5675
5913
  async function readApiKeyFromCommand(command, options = {}) {
5676
5914
  const now = options.now ?? Date.now;
@@ -5763,6 +6001,7 @@ const denialBudgetSchema = strictObject({
5763
6001
  const configFileSchema = strictObject({
5764
6002
  classifiers: record(text, unknown()).optional(),
5765
6003
  scopeSources: record(text, unknown()).optional(),
6004
+ protectedBranches: array(text).optional(),
5766
6005
  decision: strictObject({
5767
6006
  classifier: text.optional(),
5768
6007
  judge: text.nullable().optional(),
@@ -5802,6 +6041,7 @@ function buildConfig(json, path, home) {
5802
6041
  judge,
5803
6042
  judgeOverturns: decision.judgeOverturns ?? "any",
5804
6043
  scopeSources: configuredSources ?? DEFAULT_SCOPE_SOURCES,
6044
+ ...file.protectedBranches === void 0 ? {} : { protectedBranches: file.protectedBranches },
5805
6045
  classifierPath: expandHomePath(policy.frameworkPath ?? void 0, home),
5806
6046
  rulesPath: expandHomePath(policy.rulesPath ?? void 0, home),
5807
6047
  onFailure: decision.onFailure ?? DEFAULT_CONFIG.onFailure,
@@ -6032,236 +6272,6 @@ async function tryClassifyEdit(request, scope, host, reader = {}) {
6032
6272
  }
6033
6273
  }
6034
6274
  //#endregion
6035
- //#region mods/auto-mode/contract/find-current-direct-user-message.ts
6036
- function findCurrentDirectUserMessage(message) {
6037
- return message === null || message === void 0 || message.freshness === "stale" ? null : message.text;
6038
- }
6039
- //#endregion
6040
- //#region src/budget/build-retry-key.ts
6041
- const DESCRIPTIVE_FIELDS = { Bash: [
6042
- "description",
6043
- "timeout",
6044
- "run_in_background"
6045
- ] };
6046
- function buildRetryKey(request) {
6047
- const ignored = Object.hasOwn(DESCRIPTIVE_FIELDS, request.toolName) ? DESCRIPTIVE_FIELDS[request.toolName] ?? [] : [];
6048
- const input = Object.fromEntries(Object.entries(request.toolInput).filter(([key]) => !ignored.includes(key)));
6049
- const message = request.decisionContext?.lastDirectUserMessage ?? null;
6050
- const canonical = JSON.stringify(sortKeys({
6051
- cwd: request.cwd,
6052
- toolName: request.toolName,
6053
- toolInput: input,
6054
- lastDirectUserMessage: message?.text ?? null,
6055
- ...message?.freshness === "stale" ? { lastDirectUserMessageFreshness: "stale" } : {}
6056
- }));
6057
- return createHash("sha256").update(canonical).digest("hex");
6058
- }
6059
- function sortKeys(value) {
6060
- if (Array.isArray(value)) return value.map((item) => sortKeys(item));
6061
- if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).toSorted(([a], [b]) => a < b ? -1 : 1).map(([key, item]) => [key, sortKeys(item)]));
6062
- return value;
6063
- }
6064
- //#endregion
6065
- //#region src/budget/find-retry-deny.ts
6066
- function findRetryDeny(state, retryKey) {
6067
- if (state.lastDenied === null || state.lastDenied.retryKey !== retryKey) return null;
6068
- return {
6069
- kind: "deny",
6070
- rule: state.lastDenied.rule,
6071
- reason: state.lastDenied.reason
6072
- };
6073
- }
6074
- //#endregion
6075
- //#region src/state/load-json-file.ts
6076
- async function loadJSONFile(path, schema, fallback) {
6077
- try {
6078
- const text = await readFile(path, "utf8");
6079
- const parsed = schema.safeParse(JSON.parse(text));
6080
- return parsed.success ? parsed.data : fallback;
6081
- } catch {
6082
- return fallback;
6083
- }
6084
- }
6085
- //#endregion
6086
- //#region src/budget/types.ts
6087
- const EMPTY_DENIAL_STATE = {
6088
- consecutive: 0,
6089
- session: 0,
6090
- lastDenied: null
6091
- };
6092
- //#endregion
6093
- //#region src/budget/load-denial-state.ts
6094
- const count = number().int().nonnegative();
6095
- const stateSchema$1 = strictObject({
6096
- consecutive: count,
6097
- session: count,
6098
- lastDenied: strictObject({
6099
- retryKey: string().min(1),
6100
- rule: string(),
6101
- reason: string()
6102
- }).nullable()
6103
- });
6104
- function loadDenialState(path) {
6105
- return loadJSONFile(path, stateSchema$1, EMPTY_DENIAL_STATE);
6106
- }
6107
- //#endregion
6108
- //#region src/budget/plan-denial-budget.ts
6109
- function planDenialBudget(state, verdict, retryKey, budget) {
6110
- if (verdict === null) return {
6111
- verdict: null,
6112
- state,
6113
- escalation: false
6114
- };
6115
- if (verdict.kind === "allow") return {
6116
- verdict,
6117
- state: {
6118
- ...state,
6119
- consecutive: 0,
6120
- lastDenied: null
6121
- },
6122
- escalation: false
6123
- };
6124
- if (state.consecutive >= budget.consecutive || state.session >= budget.perSession) return {
6125
- verdict: null,
6126
- state: EMPTY_DENIAL_STATE,
6127
- escalation: true
6128
- };
6129
- const next = {
6130
- consecutive: state.consecutive + 1,
6131
- session: state.session + 1,
6132
- lastDenied: {
6133
- retryKey,
6134
- rule: verdict.rule,
6135
- reason: verdict.reason
6136
- }
6137
- };
6138
- const left = Math.min(budget.consecutive - next.consecutive, budget.perSession - next.session);
6139
- return {
6140
- verdict: {
6141
- ...verdict,
6142
- reason: `${verdict.reason} ${formatDenialsLeft(left)}`
6143
- },
6144
- state: next,
6145
- escalation: false
6146
- };
6147
- }
6148
- function formatDenialsLeft(left) {
6149
- if (left > 0) return `Denials left before auto-mode asks the user: ${left}.`;
6150
- return "This is the last denial before auto-mode asks the user. Stop now, without finishing the rest of the task, and tell the user what consent you need to continue.";
6151
- }
6152
- //#endregion
6153
- //#region src/budget/resolve-denial-state-path.ts
6154
- function resolveDenialStatePath(request, stateDir) {
6155
- const agentID = request.decisionContext?.agentID ?? "";
6156
- const key = createHash("sha256").update(`${request.sessionID}\0${agentID}`).digest("hex").slice(0, 32);
6157
- return join(stateDir, "denials", `${key}.json`);
6158
- }
6159
- //#endregion
6160
- //#region src/state/update-json-file.ts
6161
- const SYSTEM_CLOCK = {
6162
- now: Date.now,
6163
- wait: (ms) => setTimeout(ms)
6164
- };
6165
- async function updateJSONFile(path, load, update, clock = SYSTEM_CLOCK) {
6166
- await mkdir(dirname(path), {
6167
- recursive: true,
6168
- mode: 448
6169
- });
6170
- const lock = `${path}.lock`;
6171
- const owner = await claimLock(lock, clock);
6172
- try {
6173
- const previous = await load(path);
6174
- const next = update(previous);
6175
- if (next !== previous) {
6176
- const staged = `${path}.${randomUUID()}.tmp`;
6177
- await writeFile(staged, `${JSON.stringify(next)}\n`, { mode: 384 });
6178
- await rename(staged, path);
6179
- }
6180
- return {
6181
- previous,
6182
- next
6183
- };
6184
- } finally {
6185
- await removeOwnLock(lock, owner);
6186
- }
6187
- }
6188
- const STALE_LOCK_MS = 1e4;
6189
- const LOCK_WAIT_MS = 3e3;
6190
- const LOCK_POLL_MS = 10;
6191
- async function claimLock(lock, clock) {
6192
- const deadline = clock.now() + LOCK_WAIT_MS;
6193
- const owner = randomUUID();
6194
- const staged = `${lock}.${owner}`;
6195
- await writeFile(staged, owner, {
6196
- flag: "wx",
6197
- mode: 384
6198
- });
6199
- try {
6200
- for (;;) {
6201
- try {
6202
- await link(staged, lock);
6203
- return owner;
6204
- } catch (error) {
6205
- if (!hasErrorCode(error, "EEXIST")) throw error;
6206
- }
6207
- const held = await stat(lock).catch(() => null);
6208
- if (held !== null && isStale(held, clock)) {
6209
- if (await claimStaleLock(lock, staged, held, clock)) return owner;
6210
- }
6211
- if (clock.now() > deadline) throw new Error("state file lock unavailable");
6212
- await clock.wait(LOCK_POLL_MS);
6213
- }
6214
- } finally {
6215
- await rm(staged, { force: true });
6216
- }
6217
- }
6218
- function hasErrorCode(error, code) {
6219
- return error instanceof Error && "code" in error && error.code === code;
6220
- }
6221
- function isStale(file, clock) {
6222
- return clock.now() - file.mtimeMs > STALE_LOCK_MS;
6223
- }
6224
- async function claimStaleLock(lock, staged, held, clock) {
6225
- const prefix = `${lock}.${String(held.ino)}-${String(held.mtimeMs)}`;
6226
- const generation = await claimTakeoverMarker(prefix, clock);
6227
- if (generation === null) return false;
6228
- try {
6229
- const current = await stat(lock).catch(() => null);
6230
- if (current?.ino !== held.ino || current.mtimeMs !== held.mtimeMs) return false;
6231
- await rename(staged, lock);
6232
- return true;
6233
- } finally {
6234
- await Promise.all(Array.from({ length: generation + 1 }, (_, index) => rm(buildTakeoverMarkerPath(prefix, index), { force: true })));
6235
- }
6236
- }
6237
- async function claimTakeoverMarker(prefix, clock) {
6238
- for (let generation = 0;; generation += 1) {
6239
- const marker = buildTakeoverMarkerPath(prefix, generation);
6240
- try {
6241
- await writeFile(marker, "", {
6242
- flag: "wx",
6243
- mode: 384
6244
- });
6245
- return generation;
6246
- } catch (error) {
6247
- if (!hasErrorCode(error, "EEXIST")) throw error;
6248
- }
6249
- const existing = await stat(marker).catch(() => null);
6250
- if (existing === null || !isStale(existing, clock)) return null;
6251
- }
6252
- }
6253
- function buildTakeoverMarkerPath(prefix, generation) {
6254
- return `${prefix}-${String(generation)}.takeover`;
6255
- }
6256
- async function removeOwnLock(lock, owner) {
6257
- if (await readFile(lock, "utf8").catch(() => null) === owner) await rm(lock, { force: true });
6258
- }
6259
- //#endregion
6260
- //#region src/budget/update-denial-state.ts
6261
- function updateDenialState(path, update, clock) {
6262
- return updateJSONFile(path, loadDenialState, update, clock);
6263
- }
6264
- //#endregion
6265
6275
  //#region src/config/resolve-claude-config-path.ts
6266
6276
  const DEFAULT_DIRS = {
6267
6277
  "settings.json": [".claude"],
@@ -6328,14 +6338,6 @@ function parseRules(body) {
6328
6338
  };
6329
6339
  }
6330
6340
  //#endregion
6331
- //#region src/git/to-repository-slug.ts
6332
- function toRepositorySlug(reference) {
6333
- const match = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?(?<host>[^/:]+)[:/](?<owner>[^/]+)\/(?<name>[^/]+?)(?:\.git)?\/?$/iu.exec(reference);
6334
- if (match?.groups === void 0) return null;
6335
- const parts = match.groups;
6336
- return `${parts["host"] ?? ""}/${parts["owner"] ?? ""}/${parts["name"] ?? ""}`.toLowerCase();
6337
- }
6338
- //#endregion
6339
6341
  //#region src/shell/is-unresolved-word.ts
6340
6342
  function isUnresolvedWord(word) {
6341
6343
  return /[$`]/u.test(word);
@@ -6352,72 +6354,403 @@ function resolveShellPath(directory, path, home) {
6352
6354
  //#endregion
6353
6355
  //#region src/shell/split-shell-tokens.ts
6354
6356
  function splitShellTokens(text) {
6355
- const tokens = [];
6356
- let hasSubstitution = false;
6357
- let index = 0;
6358
- while (index < text.length) {
6359
- const ch = text.charAt(index);
6360
- if (/\s/u.test(ch) && ch !== "\n") {
6361
- index += 1;
6362
- continue;
6363
- }
6364
- const separator = findOperator(SEPARATORS, text, index);
6365
- if (separator !== void 0 && !text.startsWith("&>", index)) {
6366
- tokens.push({
6367
- kind: "separator",
6368
- operator: separator,
6369
- start: index,
6370
- end: index + separator.length
6371
- });
6372
- index += separator.length;
6373
- continue;
6374
- }
6375
- const word = readWord(text, index);
6376
- hasSubstitution ||= word.hasSubstitution;
6377
- const operator = findOperator(REDIRECTS, text, word.end);
6378
- const isDescriptor = operator !== void 0 && /^\d+$/u.test(word.raw);
6379
- if (word.raw !== "" && !isDescriptor) {
6380
- if (word.isUnterminated) return {
6381
- tokens: [...tokens, buildWordToken(word, index)],
6382
- hasSubstitution,
6383
- isUnterminated: true
6384
- };
6385
- tokens.push(buildWordToken(word, index));
6386
- index = word.end;
6387
- continue;
6388
- }
6389
- if (operator === void 0) {
6390
- index += 1;
6391
- continue;
6392
- }
6393
- const redirect = readRedirect(text, word.end, operator);
6394
- hasSubstitution ||= redirect.hasSubstitution;
6395
- tokens.push({
6396
- kind: "redirect",
6397
- operator: `${word.raw}${operator}`,
6398
- target: redirect.target,
6399
- start: index,
6400
- end: redirect.end
6401
- });
6402
- if (redirect.isUnterminated) return {
6403
- tokens,
6404
- hasSubstitution,
6405
- isUnterminated: true
6406
- };
6407
- index = redirect.end;
6357
+ const scanned = collectTextTokens(text, []);
6358
+ const literals = [];
6359
+ let recovered = scanned;
6360
+ while (recovered.unclosedAt !== null && !literals.includes(recovered.unclosedAt)) {
6361
+ literals.push(recovered.unclosedAt);
6362
+ recovered = collectTextTokens(text, literals);
6408
6363
  }
6409
6364
  return {
6410
- tokens,
6411
- hasSubstitution,
6412
- isUnterminated: false
6365
+ tokens: scanned.tokens,
6366
+ substitutions: recovered.substitutions,
6367
+ hasSubstitution: scanned.hasSubstitution,
6368
+ isUnterminated: scanned.unclosedAt !== null
6413
6369
  };
6414
6370
  }
6415
- const SEPARATORS = [
6416
- "&&",
6417
- "||",
6418
- ";",
6419
- "|",
6420
- "&",
6371
+ function collectTextTokens(text, literalOffsets) {
6372
+ const literals = new Set(literalOffsets);
6373
+ const top = buildLevel(text.length, null);
6374
+ let level = top;
6375
+ let hasSubstitution = false;
6376
+ let unclosedAt = null;
6377
+ const commandSubstitutions = /* @__PURE__ */ new Map();
6378
+ const withLevel = (limit, closer, run) => {
6379
+ const outer = level;
6380
+ const inner = buildLevel(limit, closer);
6381
+ level = inner;
6382
+ const result = run();
6383
+ level = outer;
6384
+ return {
6385
+ result,
6386
+ substitutions: inner.substitutions
6387
+ };
6388
+ };
6389
+ const collectTokens = (start, isArithmeticStart = false) => {
6390
+ const tokens = [];
6391
+ const heredocs = [];
6392
+ let arithmeticDepth = isArithmeticStart ? 1 : 0;
6393
+ let index = start;
6394
+ while (index < level.limit) {
6395
+ const ch = text.charAt(index);
6396
+ const isLiteral = literals.has(index);
6397
+ if (!isLiteral && ch === level.closer && level.depth === 0) return {
6398
+ tokens,
6399
+ end: index
6400
+ };
6401
+ if (text.startsWith("\\\n", index)) {
6402
+ index += 2;
6403
+ continue;
6404
+ }
6405
+ if (/\s/u.test(ch) && ch !== "\n") {
6406
+ index += 1;
6407
+ continue;
6408
+ }
6409
+ if (ch === "#") {
6410
+ const newline = text.indexOf("\n", index);
6411
+ const end = newline === -1 || newline > level.limit ? level.limit : newline;
6412
+ tokens.push({
6413
+ kind: "comment",
6414
+ text: text.slice(index, end),
6415
+ start: index,
6416
+ end
6417
+ });
6418
+ index = end;
6419
+ continue;
6420
+ }
6421
+ const separator = isLiteral ? void 0 : findOperator(SEPARATORS, text, index);
6422
+ if (separator !== void 0 && !text.startsWith("&>", index)) {
6423
+ tokens.push({
6424
+ kind: "separator",
6425
+ operator: separator,
6426
+ start: index,
6427
+ end: index + separator.length
6428
+ });
6429
+ index += separator.length;
6430
+ if (separator === "\n" && heredocs.length > 0) {
6431
+ const read = readHeredocs(index, heredocs.splice(0));
6432
+ tokens.push(...read.tokens);
6433
+ index = read.end;
6434
+ }
6435
+ continue;
6436
+ }
6437
+ const balance = level.balance;
6438
+ const word = readWord(index);
6439
+ const operator = findOperator(REDIRECTS, text, word.end);
6440
+ const isDescriptor = operator !== void 0 && /^\d+$/u.test(word.raw);
6441
+ if (word.raw !== "" && !isDescriptor) {
6442
+ tokens.push(buildWordToken(word, index));
6443
+ if (arithmeticDepth > 0 || word.raw.startsWith("((")) arithmeticDepth += level.balance - balance;
6444
+ if (word.isUnterminated) return {
6445
+ tokens,
6446
+ end: -1
6447
+ };
6448
+ index = word.end;
6449
+ continue;
6450
+ }
6451
+ if (operator === void 0) {
6452
+ index += 1;
6453
+ continue;
6454
+ }
6455
+ const opensHeredoc = (operator === "<<" || operator === "<<-") && arithmeticDepth <= 0;
6456
+ const redirect = readRedirect(word.end, operator, opensHeredoc);
6457
+ tokens.push({
6458
+ kind: "redirect",
6459
+ operator: `${word.raw}${operator}`,
6460
+ target: redirect.target,
6461
+ start: index,
6462
+ end: redirect.end
6463
+ });
6464
+ if (redirect.isUnterminated) return {
6465
+ tokens,
6466
+ end: -1
6467
+ };
6468
+ if (opensHeredoc && redirect.raw !== "") heredocs.push(buildHeredoc(redirect.raw, operator === "<<-", index));
6469
+ if (arithmeticDepth > 0) arithmeticDepth += level.balance - balance;
6470
+ index = redirect.end;
6471
+ }
6472
+ return {
6473
+ tokens,
6474
+ end: level.closer === null ? index : -1
6475
+ };
6476
+ };
6477
+ const readWord = (start) => {
6478
+ let value = "";
6479
+ let paren = null;
6480
+ let parenEnd = -1;
6481
+ let index = start;
6482
+ while (index < level.limit) {
6483
+ const ch = text.charAt(index);
6484
+ if (literals.has(index)) {
6485
+ value += ch;
6486
+ index += 1;
6487
+ continue;
6488
+ }
6489
+ if (ch === "\\") {
6490
+ value += text.slice(index, index + 2);
6491
+ index += 2;
6492
+ continue;
6493
+ }
6494
+ if (ch === "'" || ch === "\"" || text.startsWith("$'", index) && !literals.has(index + 1)) {
6495
+ const isANSI = ch === "$";
6496
+ const quoteAt = isANSI ? index + 1 : index;
6497
+ const quoted = readQuoted(quoteAt, isANSI);
6498
+ value += `${isANSI ? "$" : ""}${quoted.value}`;
6499
+ index = quoted.end;
6500
+ if (quoted.isUnterminated) return buildScannedWord(text, start, index, value, paren, true);
6501
+ continue;
6502
+ }
6503
+ if (text.startsWith("$$", index)) {
6504
+ value += "$$";
6505
+ index += 2;
6506
+ continue;
6507
+ }
6508
+ const expansion = readExpansion(index, false);
6509
+ if (expansion !== null) {
6510
+ value += text.slice(index, expansion.end);
6511
+ index = expansion.end;
6512
+ if (expansion.isUnterminated) return buildScannedWord(text, start, index, value, paren, true);
6513
+ continue;
6514
+ }
6515
+ if (/\s/u.test(ch) || /[;&|<>]/u.test(ch) || ch === "#" && index === parenEnd) break;
6516
+ if (ch === "(" || ch === ")") {
6517
+ if (level.closer === ")" && ch === ")" && level.depth === 0) break;
6518
+ if (level.closer === ")") level.depth += ch === "(" ? 1 : -1;
6519
+ level.balance += ch === "(" ? 1 : -1;
6520
+ paren ??= {
6521
+ value: value.length,
6522
+ raw: index - start
6523
+ };
6524
+ parenEnd = index + 1;
6525
+ }
6526
+ value += ch;
6527
+ index += 1;
6528
+ }
6529
+ return buildScannedWord(text, start, Math.min(index, level.limit), value, paren, false);
6530
+ };
6531
+ const readQuoted = (start, isANSI) => {
6532
+ const quote = text.charAt(start);
6533
+ let value = "";
6534
+ let index = start + 1;
6535
+ while (index < level.limit) {
6536
+ const ch = text.charAt(index);
6537
+ if (literals.has(index)) {
6538
+ value += ch;
6539
+ index += 1;
6540
+ } else if (ch === quote) return {
6541
+ value,
6542
+ end: index + 1,
6543
+ isUnterminated: false
6544
+ };
6545
+ else if (ch === "\\" && (quote === "\"" || isANSI)) {
6546
+ value += text.slice(index, index + 2);
6547
+ index += 2;
6548
+ } else {
6549
+ const expansion = quote === "\"" ? readExpansion(index, true) : null;
6550
+ const end = expansion?.end ?? index + 1;
6551
+ value += text.slice(index, end);
6552
+ index = end;
6553
+ if (expansion?.isUnterminated === true) return {
6554
+ value,
6555
+ end: index,
6556
+ isUnterminated: true
6557
+ };
6558
+ }
6559
+ }
6560
+ unclosedAt ??= start;
6561
+ return {
6562
+ value,
6563
+ end: level.limit,
6564
+ isUnterminated: true
6565
+ };
6566
+ };
6567
+ const readExpansion = (index, isInDoubleQuotes) => {
6568
+ if (text.startsWith("$(", index) || text.charAt(index) === "`") {
6569
+ hasSubstitution = true;
6570
+ const isBacktick = text.charAt(index) === "`";
6571
+ const bodyStart = index + (isBacktick ? 1 : 2);
6572
+ if (isBacktick) return collectExpansionBody(index, bodyStart, findBacktickEnd(bodyStart));
6573
+ return readCommandSubstitution(index, bodyStart);
6574
+ }
6575
+ if (text.startsWith("${", index)) return collectExpansionBody(index, -1, findParameterEnd(index + 2, isInDoubleQuotes));
6576
+ return null;
6577
+ };
6578
+ const readCommandSubstitution = (index, bodyStart) => {
6579
+ const key = `${index}:${level.limit}`;
6580
+ const scan = commandSubstitutions.get(key);
6581
+ if (scan !== void 0) {
6582
+ level.substitutions.push(...scan.substitutions);
6583
+ unclosedAt ??= scan.unclosedAt;
6584
+ return {
6585
+ end: scan.end,
6586
+ isUnterminated: scan.isUnterminated
6587
+ };
6588
+ }
6589
+ const collected = level.substitutions.length;
6590
+ const wasUnclosed = unclosedAt !== null;
6591
+ const read = readCommandSubstitutionBody(index, bodyStart);
6592
+ commandSubstitutions.set(key, {
6593
+ ...read,
6594
+ substitutions: level.substitutions.slice(collected),
6595
+ unclosedAt: wasUnclosed ? null : unclosedAt
6596
+ });
6597
+ return read;
6598
+ };
6599
+ const readCommandSubstitutionBody = (index, bodyStart) => {
6600
+ if (text.charAt(bodyStart) === "(") {
6601
+ const arithmetic = withLevel(level.limit, ")", () => collectTokens(bodyStart + 1, true).end);
6602
+ if (arithmetic.result !== -1 && text.charAt(arithmetic.result + 1) === ")") {
6603
+ level.substitutions.push(...arithmetic.substitutions);
6604
+ return {
6605
+ end: arithmetic.result + 2,
6606
+ isUnterminated: false
6607
+ };
6608
+ }
6609
+ }
6610
+ const end = withLevel(level.limit, ")", () => collectTokens(bodyStart).end).result;
6611
+ return collectExpansionBody(index, bodyStart, end);
6612
+ };
6613
+ const collectExpansionBody = (index, bodyStart, end) => {
6614
+ if (end === -1) {
6615
+ unclosedAt ??= index;
6616
+ return {
6617
+ end: level.limit,
6618
+ isUnterminated: true
6619
+ };
6620
+ }
6621
+ if (bodyStart !== -1) level.substitutions.push(text.slice(bodyStart, end));
6622
+ return {
6623
+ end: end + 1,
6624
+ isUnterminated: false
6625
+ };
6626
+ };
6627
+ const findBacktickEnd = (start) => {
6628
+ for (let index = start; index < level.limit; index += 1) {
6629
+ if (literals.has(index)) continue;
6630
+ if (text.charAt(index) === "\\") index += 1;
6631
+ else if (text.charAt(index) === "`") return index;
6632
+ }
6633
+ return -1;
6634
+ };
6635
+ const findParameterEnd = (start, isInDoubleQuotes) => {
6636
+ let isDoubleQuoted = false;
6637
+ let isSingleQuoted = false;
6638
+ let index = start;
6639
+ while (index < level.limit) {
6640
+ const ch = text.charAt(index);
6641
+ if (literals.has(index)) {
6642
+ index += 1;
6643
+ continue;
6644
+ }
6645
+ if (ch === "\\") {
6646
+ index += 2;
6647
+ continue;
6648
+ }
6649
+ const expansion = readExpansion(index, isInDoubleQuotes || isDoubleQuoted);
6650
+ if (expansion !== null) {
6651
+ if (expansion.isUnterminated) return -1;
6652
+ index = expansion.end;
6653
+ continue;
6654
+ }
6655
+ if (ch === "'" && !isInDoubleQuotes && !isDoubleQuoted) {
6656
+ const close = text.indexOf("'", index + 1);
6657
+ if (close === -1 || close >= level.limit) return -1;
6658
+ index = close + 1;
6659
+ continue;
6660
+ }
6661
+ if (ch === "'" && !isDoubleQuoted) isSingleQuoted = !isSingleQuoted;
6662
+ else if (ch === "\"" && !isSingleQuoted) isDoubleQuoted = !isDoubleQuoted;
6663
+ else if (!isDoubleQuoted && !isSingleQuoted && ch === "}") return index;
6664
+ index += 1;
6665
+ }
6666
+ return -1;
6667
+ };
6668
+ const readRedirect = (start, operator, opensHeredoc) => {
6669
+ let index = start + operator.length;
6670
+ if (operator.endsWith("(")) {
6671
+ hasSubstitution = true;
6672
+ return {
6673
+ target: null,
6674
+ raw: "",
6675
+ ...collectExpansionBody(start, index, withLevel(level.limit, ")", () => collectTokens(index).end).result)
6676
+ };
6677
+ }
6678
+ while (index < level.limit) if (text.startsWith("\\\n", index)) index += 2;
6679
+ else if (/\s/u.test(text.charAt(index)) && text.charAt(index) !== "\n") index += 1;
6680
+ else break;
6681
+ const collected = level.substitutions.length;
6682
+ const hadSubstitution = hasSubstitution;
6683
+ const word = readWord(index);
6684
+ if (opensHeredoc) {
6685
+ level.substitutions.splice(collected);
6686
+ hasSubstitution = hadSubstitution;
6687
+ }
6688
+ if (word.raw === "") return {
6689
+ target: null,
6690
+ raw: "",
6691
+ end: index,
6692
+ isUnterminated: false
6693
+ };
6694
+ return {
6695
+ target: word.paren === null ? word.value : word.value.slice(0, word.paren.value),
6696
+ raw: word.paren === null ? word.raw : word.raw.slice(0, word.paren.raw),
6697
+ end: word.end,
6698
+ isUnterminated: word.isUnterminated
6699
+ };
6700
+ };
6701
+ const readHeredocs = (start, heredocs) => {
6702
+ const tokens = [];
6703
+ let bodyStart = start;
6704
+ let end = start;
6705
+ for (const heredoc of heredocs) {
6706
+ const [bodyEnd, next] = findHeredocEnd(text, bodyStart, level.limit, heredoc);
6707
+ const substitutions = heredoc.isQuoted ? [] : collectHeredocSubstitutions(bodyStart, bodyEnd);
6708
+ tokens.push({
6709
+ kind: "heredoc",
6710
+ body: text.slice(bodyStart, bodyEnd),
6711
+ isQuoted: heredoc.isQuoted,
6712
+ opener: heredoc.opener,
6713
+ substitutions,
6714
+ start: bodyStart,
6715
+ end: next
6716
+ });
6717
+ level.substitutions.push(...substitutions);
6718
+ end = next;
6719
+ bodyStart = Math.min(next + 1, level.limit);
6720
+ }
6721
+ return {
6722
+ tokens,
6723
+ end
6724
+ };
6725
+ };
6726
+ const collectHeredocSubstitutions = (start, end) => withLevel(end, null, () => {
6727
+ let index = start;
6728
+ while (index < end) if (literals.has(index)) index += 1;
6729
+ else if (text.charAt(index) === "\\") index += 2;
6730
+ else index = readExpansion(index, true)?.end ?? index + 1;
6731
+ }).substitutions;
6732
+ return {
6733
+ tokens: collectTokens(0).tokens,
6734
+ substitutions: top.substitutions,
6735
+ hasSubstitution,
6736
+ unclosedAt
6737
+ };
6738
+ }
6739
+ function buildLevel(limit, closer) {
6740
+ return {
6741
+ substitutions: [],
6742
+ limit,
6743
+ closer,
6744
+ depth: 0,
6745
+ balance: 0
6746
+ };
6747
+ }
6748
+ const SEPARATORS = [
6749
+ "&&",
6750
+ "||",
6751
+ ";",
6752
+ "|",
6753
+ "&",
6421
6754
  "\n"
6422
6755
  ];
6423
6756
  const REDIRECTS = [
@@ -6448,63 +6781,119 @@ function buildWordToken(word, start) {
6448
6781
  end: word.end
6449
6782
  };
6450
6783
  }
6451
- function readWord(text, start) {
6452
- let value = "";
6784
+ function buildScannedWord(text, start, end, value, paren, isUnterminated) {
6785
+ return {
6786
+ value,
6787
+ raw: text.slice(start, end),
6788
+ end,
6789
+ paren,
6790
+ isUnterminated
6791
+ };
6792
+ }
6793
+ function buildHeredoc(raw, stripsTabs, opener) {
6794
+ let delimiter = "";
6453
6795
  let quote = null;
6454
- let hasSubstitution = false;
6455
- let parenAt = null;
6456
- let index = start;
6457
- while (index < text.length) {
6458
- const ch = text.charAt(index);
6459
- if (quote !== null) {
6460
- if (quote === "\"" && (text.startsWith("$(", index) || ch === "`")) hasSubstitution = true;
6461
- if (ch === quote) quote = null;
6462
- else value += ch;
6796
+ let isQuoted = false;
6797
+ let index = 0;
6798
+ while (index < raw.length) {
6799
+ const ch = raw.charAt(index);
6800
+ const next = raw.charAt(index + 1);
6801
+ if (quote === "$'" && ch === "\\") {
6802
+ const escape = parseANSIEscape(raw, index);
6803
+ delimiter += escape.text;
6804
+ index = escape.end;
6805
+ } else if (quote === "$'" && ch === "'") {
6806
+ quote = null;
6463
6807
  index += 1;
6464
- continue;
6465
- }
6466
- if (ch === "'" || ch === "\"") {
6467
- quote = ch;
6808
+ } else if (quote === "$'") {
6809
+ delimiter += ch;
6468
6810
  index += 1;
6469
- continue;
6470
- }
6471
- if (ch === "\\") {
6472
- value += text.slice(index, index + 2);
6811
+ } else if (quote === null && ch === "$" && (next === "'" || next === "\"")) {
6812
+ quote = next === "'" ? "$'" : "\"";
6813
+ isQuoted = true;
6473
6814
  index += 2;
6474
- continue;
6815
+ } else if (quote === "'") {
6816
+ quote = ch === "'" ? null : quote;
6817
+ delimiter += ch === "'" ? "" : ch;
6818
+ index += 1;
6819
+ } else if (ch === quote) {
6820
+ quote = null;
6821
+ index += 1;
6822
+ } else if (ch === "\\" && (quote === null || /[$`"\\\n]/u.test(next))) {
6823
+ isQuoted ||= next !== "\n";
6824
+ delimiter += next === "\n" ? "" : next;
6825
+ index += 2;
6826
+ } else if (quote === null && (ch === "'" || ch === "\"")) {
6827
+ quote = ch;
6828
+ isQuoted = true;
6829
+ index += 1;
6830
+ } else {
6831
+ delimiter += ch;
6832
+ index += 1;
6475
6833
  }
6476
- if (/\s/u.test(ch) || /[;&|<>]/u.test(ch)) break;
6477
- if (text.startsWith("$(", index) || ch === "`") hasSubstitution = true;
6478
- if ((ch === "(" || ch === ")") && parenAt === null) parenAt = value.length;
6479
- value += ch;
6480
- index += 1;
6481
6834
  }
6482
6835
  return {
6483
- value,
6484
- raw: text.slice(start, Math.min(index, text.length)),
6485
- end: Math.min(index, text.length),
6486
- parenAt,
6487
- hasSubstitution,
6488
- isUnterminated: quote !== null
6489
- };
6490
- }
6491
- function readRedirect(text, start, operator) {
6492
- let index = start + operator.length;
6493
- while (index < text.length && /\s/u.test(text.charAt(index)) && text.charAt(index) !== "\n") index += 1;
6494
- const word = readWord(text, index);
6495
- const isProcess = operator.endsWith("(");
6496
- if (word.raw === "") return {
6497
- target: null,
6498
- end: index,
6499
- hasSubstitution: isProcess,
6500
- isUnterminated: false
6836
+ delimiter,
6837
+ isQuoted,
6838
+ stripsTabs,
6839
+ opener
6840
+ };
6841
+ }
6842
+ const ANSI_ESCAPES = {
6843
+ a: "\x07",
6844
+ b: "\b",
6845
+ e: "\x1B",
6846
+ E: "\x1B",
6847
+ f: "\f",
6848
+ n: "\n",
6849
+ r: "\r",
6850
+ t: " ",
6851
+ v: "\v",
6852
+ "\\": "\\",
6853
+ "'": "'",
6854
+ "\"": "\"",
6855
+ "?": "?"
6856
+ };
6857
+ function parseANSIEscape(raw, index) {
6858
+ const next = raw.charAt(index + 1);
6859
+ const named = ANSI_ESCAPES[next];
6860
+ const hex = /^x(?<digits>[0-9A-Fa-f]{1,2})/u.exec(raw.slice(index + 1));
6861
+ const octal = /^[0-7]{1,3}/u.exec(raw.slice(index + 1));
6862
+ if (named !== void 0) return {
6863
+ text: named,
6864
+ end: index + 2
6865
+ };
6866
+ if (hex !== null) return {
6867
+ text: String.fromCodePoint(Number.parseInt(hex.groups?.["digits"] ?? "", 16)),
6868
+ end: index + 1 + hex[0].length
6869
+ };
6870
+ if (octal !== null) return {
6871
+ text: String.fromCodePoint(Number.parseInt(octal[0], 8)),
6872
+ end: index + 1 + octal[0].length
6501
6873
  };
6502
6874
  return {
6503
- target: word.parenAt === null ? word.value : word.value.slice(0, word.parenAt),
6504
- end: word.end,
6505
- hasSubstitution: isProcess || word.hasSubstitution,
6506
- isUnterminated: word.isUnterminated
6507
- };
6875
+ text: raw.slice(index, index + 2),
6876
+ end: index + 2
6877
+ };
6878
+ }
6879
+ function findHeredocEnd(text, bodyStart, limit, heredoc) {
6880
+ let lineStart = bodyStart;
6881
+ while (lineStart < limit) {
6882
+ let lineEnd = findLineEnd(text, lineStart, limit);
6883
+ let line = text.slice(lineStart, lineEnd);
6884
+ while (!heredoc.isQuoted && /(?<!\\)(?:\\\\)*\\$/u.test(line) && lineEnd < limit) {
6885
+ const nextEnd = findLineEnd(text, lineEnd + 1, limit);
6886
+ line = `${line.slice(0, -1)}${text.slice(lineEnd + 1, nextEnd)}`;
6887
+ lineEnd = nextEnd;
6888
+ }
6889
+ if ((heredoc.stripsTabs ? line.replace(/^\t+/u, "") : line) === heredoc.delimiter) return [lineStart, lineEnd];
6890
+ lineStart = lineEnd + 1;
6891
+ }
6892
+ return [limit, limit];
6893
+ }
6894
+ function findLineEnd(text, start, limit) {
6895
+ const newline = text.indexOf("\n", start);
6896
+ return newline === -1 || newline > limit ? limit : newline;
6508
6897
  }
6509
6898
  //#endregion
6510
6899
  //#region src/shell/split-command-words.ts
@@ -6530,9 +6919,13 @@ function splitShellCommand(command) {
6530
6919
  let start = null;
6531
6920
  let end = 0;
6532
6921
  for (const token of [...scanned.tokens, null]) if (token === null || token.kind === "separator") {
6533
- if (start !== null) segments.push({ text: command.slice(start, end) });
6922
+ if (start !== null) segments.push({
6923
+ text: command.slice(start, end),
6924
+ start,
6925
+ end
6926
+ });
6534
6927
  start = null;
6535
- } else {
6928
+ } else if (token.kind !== "comment" && token.kind !== "heredoc") {
6536
6929
  start ??= token.start;
6537
6930
  end = token.end;
6538
6931
  }
@@ -6568,52 +6961,110 @@ function collectWriteTargets(text) {
6568
6961
  return splitShellTokens(text).tokens.flatMap((token) => token.kind === "redirect" && token.target !== null && token.target !== "" && WRITE_REDIRECTS.has(token.operator.replace(/^\d+/u, "")) ? [token.target] : []);
6569
6962
  }
6570
6963
  //#endregion
6571
- //#region src/shell/split-git-command.ts
6572
- function splitGitCommand(args, directory, home) {
6573
- let gitDirectory = directory;
6964
+ //#region src/shell/collect-shell-scripts.ts
6965
+ function collectShellScripts(command) {
6966
+ const segments = splitShellCommand(command).segments;
6967
+ const tokens = splitShellTokens(command).tokens;
6968
+ return [...segments.flatMap((range, segment) => splitShellTokens(range.text).substitutions.map((body) => ({
6969
+ text: body,
6970
+ segment
6971
+ }))), ...tokens.flatMap((token) => {
6972
+ if (token.kind !== "heredoc") return [];
6973
+ const segment = segments.findIndex((range) => range.start <= token.opener && token.opener < range.end);
6974
+ const range = segments[segment];
6975
+ const descriptor = range === void 0 ? null : findScriptDescriptor(range.text);
6976
+ return (range !== void 0 && descriptor !== null && findDescriptorSource(tokens, range.start, range.end, descriptor) === token.opener && (token.isQuoted || token.substitutions.length === 0) ? [...token.substitutions, token.body] : token.substitutions).map((body) => ({
6977
+ text: body,
6978
+ segment
6979
+ }));
6980
+ })];
6981
+ }
6982
+ function findDescriptorSource(tokens, start, end, descriptor) {
6983
+ const descriptors = /* @__PURE__ */ new Map();
6984
+ for (const token of tokens) {
6985
+ if (token.kind !== "redirect" || token.operator.endsWith("(") || token.start < start || token.start >= end) continue;
6986
+ const number = /^\d*/u.exec(token.operator)?.[0] ?? "";
6987
+ const operator = token.operator.slice(number.length);
6988
+ const implied = operator.startsWith("<") ? "0" : "1";
6989
+ const assigned = number === "" ? implied : String(Number(number));
6990
+ const move = /^(?<source>\d+)(?<close>-?)$/u.exec(token.target ?? "")?.groups;
6991
+ const path = operator === "<" || operator === "<>" ? findPathDescriptor(token.target ?? "") : null;
6992
+ if (operator.endsWith("&") && move !== void 0) {
6993
+ const source = String(Number(move["source"]));
6994
+ descriptors.set(assigned, descriptors.get(source) ?? null);
6995
+ if (move["close"] === "-") descriptors.delete(source);
6996
+ } else if (path === null) descriptors.set(assigned, token.start);
6997
+ else descriptors.set(assigned, descriptors.get(path) ?? null);
6998
+ }
6999
+ return descriptors.get(descriptor) ?? null;
7000
+ }
7001
+ const STANDARD_PATHS = /* @__PURE__ */ new Map([
7002
+ ["/dev/stdin", "0"],
7003
+ ["/dev/stdout", "1"],
7004
+ ["/dev/stderr", "2"]
7005
+ ]);
7006
+ function findPathDescriptor(path) {
7007
+ const descriptor = /^\/(?:dev|proc\/self)\/fd\/(?<number>\d+)$/u.exec(path)?.groups?.["number"];
7008
+ if (descriptor !== void 0) return String(Number(descriptor));
7009
+ return STANDARD_PATHS.get(path) ?? null;
7010
+ }
7011
+ const SHELLS = /* @__PURE__ */ new Set([
7012
+ "bash",
7013
+ "sh",
7014
+ "zsh",
7015
+ "dash",
7016
+ "ksh"
7017
+ ]);
7018
+ const VALUE_OPTIONS = /* @__PURE__ */ new Set([
7019
+ "--rcfile",
7020
+ "--init-file",
7021
+ "--emulate"
7022
+ ]);
7023
+ const VALUE_OPTION_GROUP = /^[-+][^-]*[oO]$/u;
7024
+ function findScriptDescriptor(segment) {
7025
+ const [program = "", ...args] = splitCommandWords(segment);
7026
+ if (!SHELLS.has(basename(program))) return null;
7027
+ let readsInput = false;
6574
7028
  let index = 0;
6575
- while (index < args.length && args[index]?.startsWith("-") === true) {
6576
- const option = args[index];
6577
- if (option === "-C") gitDirectory = resolveShellPath(gitDirectory, args[index + 1] ?? "", home);
6578
- index += option === "-C" || option === "-c" ? 2 : 1;
7029
+ while (index < args.length) {
7030
+ const arg = args[index] ?? "";
7031
+ if (/^-[^-]*c/u.test(arg)) return null;
7032
+ if (arg === "--" || arg === "-") {
7033
+ const script = args[index + 1];
7034
+ return readsInput || script === void 0 ? "0" : findPathDescriptor(script);
7035
+ }
7036
+ if (!/^[-+]/u.test(arg)) return readsInput ? "0" : findPathDescriptor(arg);
7037
+ readsInput ||= /^-[^-]*s/u.test(arg);
7038
+ index += VALUE_OPTIONS.has(arg) || VALUE_OPTION_GROUP.test(arg) ? 2 : 1;
6579
7039
  }
6580
- return {
6581
- directory: gitDirectory,
6582
- args: args.slice(index)
6583
- };
7040
+ return "0";
6584
7041
  }
6585
7042
  //#endregion
6586
- //#region src/containment/collect-scope-findings.ts
6587
- function collectScopeFindings(action, scope, scratchPaths = SCRATCH_PATHS) {
6588
- const filePath = action.input["file_path"] ?? action.input["notebook_path"];
6589
- const command = action.input["command"];
6590
- const findings = [
6591
- ...FILE_WRITE_TOOLS.has(action.tool) && typeof filePath === "string" ? collectTargetFindings(action.cwd, filePath, scope, scratchPaths) : [],
6592
- ...action.tool.startsWith("mcp__") ? collectToolFindings(action, scope) : [],
6593
- ...action.tool === "Bash" && typeof command === "string" ? collectCommandFindings(command, action.cwd, scope, scratchPaths) : []
6594
- ];
6595
- return findings.filter((finding, index) => findings.findIndex((other) => other.kind === finding.kind && other.target === finding.target) === index);
7043
+ //#region src/git/to-repository-slug.ts
7044
+ function toRepositorySlug(reference) {
7045
+ const match = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?(?<host>[^/:]+)[:/](?<owner>[^/]+)\/(?<name>[^/]+?)(?:\.git)?\/?$/iu.exec(reference);
7046
+ if (match?.groups === void 0) return null;
7047
+ const parts = match.groups;
7048
+ return `${parts["host"] ?? ""}/${parts["owner"] ?? ""}/${parts["name"] ?? ""}`.toLowerCase();
6596
7049
  }
6597
- const FILE_WRITE_TOOLS = /* @__PURE__ */ new Set([
6598
- "Edit",
6599
- "MultiEdit",
6600
- "Write",
6601
- "NotebookEdit"
6602
- ]);
7050
+ //#endregion
7051
+ //#region src/containment/is-in-scope.ts
7052
+ function isInScope(path, scope, scratchPaths) {
7053
+ if (scratchPaths.some((entry) => isUnder$2(path, entry)) || scope.pathGlobs.some((glob) => matchesGlob(path, glob))) return true;
7054
+ return scope.worktrees.some((worktree) => {
7055
+ const isNested = isUnder$2(path, join(worktree, ".worktrees"));
7056
+ const isOtherOwned = scope.worktrees.some((other) => other !== worktree && isUnder$2(path, other));
7057
+ return isUnder$2(path, worktree) && (!isNested || isOtherOwned);
7058
+ });
7059
+ }
7060
+ function isUnder$2(path, root) {
7061
+ return path === root || path.startsWith(`${root}/`);
7062
+ }
7063
+ //#endregion
7064
+ //#region src/containment/collect-target-findings.ts
6603
7065
  function collectTargetFindings(directory, target, scope, scratchPaths) {
6604
7066
  const path = resolveShellPath(directory, target, scope.home);
6605
- return path === null ? [] : collectPathFindings(path, scope, scratchPaths);
6606
- }
6607
- const CREDENTIAL_PATHS = [
6608
- ".ssh",
6609
- ".aws",
6610
- ".gnupg",
6611
- ".gitconfig",
6612
- ".netrc",
6613
- ".npmrc",
6614
- ".config/gh"
6615
- ];
6616
- function collectPathFindings(path, scope, scratchPaths) {
7067
+ if (path === null) return [];
6617
7068
  if (CREDENTIAL_PATHS.some((entry) => isUnder$1(path, join(scope.home, entry)))) return [{
6618
7069
  kind: "credential",
6619
7070
  target: path
@@ -6624,213 +7075,46 @@ function collectPathFindings(path, scope, scratchPaths) {
6624
7075
  grant: { kind: "path" }
6625
7076
  }];
6626
7077
  }
6627
- function collectToolFindings(action, scope) {
6628
- const verb = action.tool.split("__").at(-1) ?? "";
6629
- if (!/merge|create|comment|update|delete|push|close|review/u.test(verb)) return [];
6630
- const number = action.input["pullNumber"] ?? action.input["pull_number"] ?? action.input["issue_number"];
6631
- const owner = action.input["owner"];
6632
- const name = action.input["repo"];
6633
- const repo = typeof owner === "string" && typeof name === "string" ? `${owner}/${name}` : void 0;
6634
- if (!verb.includes("merge") && typeof number === "number" && isOwnedPullRequest(number, repo, scope)) return [];
6635
- return typeof number === "number" ? [{
6636
- kind: "remote-write",
6637
- target: action.tool,
6638
- grant: {
6639
- kind: "pull-request",
6640
- number
6641
- }
6642
- }] : [{
6643
- kind: "remote-write",
6644
- target: action.tool
6645
- }];
7078
+ const CREDENTIAL_PATHS = [
7079
+ ".ssh",
7080
+ ".aws",
7081
+ ".gnupg",
7082
+ ".gitconfig",
7083
+ ".netrc",
7084
+ ".npmrc",
7085
+ ".config/gh"
7086
+ ];
7087
+ function isUnder$1(path, root) {
7088
+ return path === root || path.startsWith(`${root}/`);
6646
7089
  }
6647
- const PATH_WRITERS = /* @__PURE__ */ new Set([
6648
- "rm",
6649
- "rmdir",
6650
- "shred",
6651
- "truncate",
6652
- "chmod",
6653
- "chown"
6654
- ]);
6655
- const DESTINATION_WRITERS = /* @__PURE__ */ new Set([
6656
- "cp",
6657
- "mv",
6658
- "rsync",
6659
- "ln"
6660
- ]);
6661
- function collectCommandFindings(command, cwd, scope, scratchPaths) {
6662
- const findings = [];
6663
- const entries = collectShellCommands(command, cwd, scope.home);
6664
- const piped = collectPipedSegments(command);
6665
- const segments = splitShellCommand(command).segments;
6666
- for (const [index, entry] of entries.entries()) {
6667
- const [name, ...args] = entry.words;
6668
- const fixed = findFixedXargsCommand(entry.words, segments[index]?.text ?? "");
6669
- if (fixed !== null) findings.push(...collectProgramFindings(fixed.name, fixed.args, entry.directory, cwd, scope, scratchPaths));
6670
- else if (isBranchSweep(entries.slice(0, index + 1), piped.slice(0, index + 1))) findings.push({
6671
- kind: "discard",
6672
- target: "git branch delete by exclusion"
6673
- });
6674
- for (const target of entry.writeTargets) findings.push(...collectTargetFindings(entry.directory, target, scope, scratchPaths));
6675
- if (name !== void 0 && name !== "cd") findings.push(...collectProgramFindings(name, args, entry.directory, cwd, scope, scratchPaths));
6676
- }
6677
- return findings;
7090
+ //#endregion
7091
+ //#region src/containment/is-owned-pull-request.ts
7092
+ function isOwnedPullRequest(number, repo, scope) {
7093
+ const wanted = repo?.toLowerCase().replace(/\.git$/u, "");
7094
+ const slugs = new Set(scope.remotes.map((remote) => toRepositorySlug(remote.url)));
7095
+ return scope.pullRequests.some((pull) => pull.number === number && (wanted === void 0 ? slugs.size === 1 && slugs.has(pull.repository) : pull.repository === wanted || pull.repository.endsWith(`/${wanted}`)));
6678
7096
  }
6679
- function collectPipedSegments(command) {
6680
- const piped = [];
6681
- let separator = null;
6682
- let isInSegment = false;
6683
- for (const token of splitShellTokens(command).tokens) if (token.kind === "separator") {
6684
- separator = token.operator;
6685
- isInSegment = false;
6686
- } else if (!isInSegment) {
6687
- piped.push(separator === "|");
6688
- isInSegment = true;
6689
- }
6690
- return piped;
6691
- }
6692
- const XARGS_FLAG = /^(?:-[0oprtx]+|--(?:null|no-run-if-empty|verbose|interactive|exit|open-tty))$/u;
6693
- const XARGS_VALUE_OPTION = /^-[EPads]$/u;
6694
- const XARGS_ATTACHED_VALUE = /^(?:-[EPads].|--(?:eof|max-procs|delimiter|arg-file|max-chars|process-slot-var)=)/u;
6695
- const XARGS_REPLACE = /^(?:-I(?<attached>.+)|-i(?<short>.*)|--replace(?:=(?<long>.*))?)$/u;
6696
- const PLAIN_WORD = /^(?:[\w./:@%+=-]|\{\})+$/u;
6697
- function findFixedXargsCommand(words, text) {
6698
- if (words[0] !== "xargs" || !splitShellTokens(text).tokens.every((token) => token.kind !== "word" || token.raw === token.value && PLAIN_WORD.test(token.value))) return null;
6699
- let token = null;
6700
- let start = 1;
6701
- while (words[start]?.startsWith("-") === true) {
6702
- const word = words[start] ?? "";
6703
- const replace = XARGS_REPLACE.exec(word)?.groups;
6704
- if (word === "--") {
6705
- start += 1;
6706
- break;
6707
- }
6708
- if (word === "-I") {
6709
- token = words[start + 1] ?? null;
6710
- start += 2;
6711
- } else if (replace !== void 0) {
6712
- const short = replace["short"];
6713
- token = replace["attached"] ?? replace["long"] ?? (short === void 0 || short === "" ? "{}" : short);
6714
- start += 1;
6715
- } else if (word.startsWith("-J")) {
6716
- token = null;
6717
- start += word === "-J" ? 2 : 1;
6718
- } else if (XARGS_VALUE_OPTION.test(word)) start += 2;
6719
- else if (XARGS_FLAG.test(word) || XARGS_ATTACHED_VALUE.test(word)) start += 1;
6720
- else return null;
6721
- }
6722
- const [name, ...args] = words.slice(start);
6723
- if (token === null || token === "" || name === void 0 || [name, ...args].some((word) => word.includes(token))) return null;
6724
- return {
6725
- name,
6726
- args
6727
- };
6728
- }
6729
- const XARGS_VALUE_OPTIONS = /* @__PURE__ */ new Set([
6730
- "-I",
6731
- "-J",
6732
- "-L",
6733
- "-n",
6734
- "-P",
6735
- "-s",
6736
- "-d",
6737
- "-E"
6738
- ]);
6739
- function isBranchSweep(entries, piped) {
6740
- const deleter = entries.at(-1)?.words ?? [];
6741
- if (deleter[0] !== "xargs" || piped.at(-1) !== true) return false;
6742
- let start = 1;
6743
- while (deleter[start]?.startsWith("-") === true) {
6744
- if (/^(?:-a|--arg-file)/u.test(deleter[start] ?? "")) return false;
6745
- start += XARGS_VALUE_OPTIONS.has(deleter[start] ?? "") ? 2 : 1;
6746
- }
6747
- const [program, subcommand, ...rest] = deleter.slice(start);
6748
- if (program !== "git" || subcommand !== "branch" || !rest.some((word) => /^-(?:d|D|-delete)$/u.test(word))) return false;
6749
- let first = entries.length - 2;
6750
- while (first > 0 && piped[first] === true) first -= 1;
6751
- const [listing, ...filters] = entries.slice(first, -1).map((entry) => entry.words);
6752
- return listing !== void 0 && isWholeBranchListing(listing) && filters.every((filter) => isExcludingFilter(filter));
6753
- }
6754
- function isWholeBranchListing(words) {
6755
- const [program, subcommand, ...rest] = words;
6756
- if (program !== "git") return false;
6757
- if (subcommand === "branch") return rest.every((word) => /^(?:-a|-r|-v|-vv|--all|--remotes|--no-color|--format=.*)$/u.test(word));
6758
- if (subcommand !== "for-each-ref") return false;
6759
- return rest.filter((_word, index) => !/^--(?:format|sort)$/u.test(rest[index - 1] ?? "")).every((word) => /^--(?:format|sort)(?:=.*)?$/u.test(word) || /^refs\/heads\/?$/u.test(word));
6760
- }
6761
- const NAME_EDITORS = /* @__PURE__ */ new Set([
6762
- "tr",
6763
- "cut",
6764
- "sort"
6765
- ]);
6766
- function isExcludingFilter(words) {
6767
- const [program, ...args] = words;
6768
- if (program === "grep" || program === "egrep") return args.some((word) => /^-[a-zA-Z]*v[a-zA-Z]*$|^--invert-match$/u.test(word));
6769
- if (program === "sed") return isSubstitutionOnly(args);
6770
- if (program === "uniq") return args.every((word) => !/^-[a-zA-Z]*[dDu]/u.test(word));
6771
- return program !== void 0 && NAME_EDITORS.has(program);
6772
- }
6773
- function isSubstitutionOnly(args) {
6774
- const scripts = args.filter((word) => word !== "-e" && word !== "-E" && word !== "-r");
6775
- return scripts.length > 0 && scripts.every((script) => /^s(?<delimiter>[^\w\s;])(?:(?!\k<delimiter>).)*\k<delimiter>(?:(?!\k<delimiter>).)*\k<delimiter>g?$/u.test(script));
6776
- }
6777
- function collectProgramFindings(name, args, directory, cwd, scope, scratchPaths) {
6778
- const resolve = (path) => collectTargetFindings(directory, path, scope, scratchPaths);
6779
- const operands = args.filter((word) => !word.startsWith("-"));
6780
- if (name === "git") return collectGitFindings(args, directory, cwd, scope, scratchPaths);
6781
- if (name === "gh") return collectGhFindings(args, scope);
6782
- if (PATH_WRITERS.has(name)) return operands.flatMap((path) => resolve(path));
6783
- if (DESTINATION_WRITERS.has(name)) return collectCopyFindings(name, args, operands).flatMap((path) => resolve(path));
6784
- if (name === "find" && args.some((word) => word === "-delete" || word === "-exec")) return operands.slice(0, 1).flatMap((path) => resolve(path));
6785
- if ((name === "perl" || name === "sed") && args.some((word) => /^-\w*i/u.test(word))) return collectInPlaceFiles(args).flatMap((path) => resolve(path));
6786
- if (name === "ssh-keygen") return collectKeygenFiles(args).flatMap((path) => resolve(path));
6787
- if (name === "ssh") {
6788
- const logIndex = args.indexOf("-E");
6789
- const log = logIndex === -1 ? void 0 : args[logIndex + 1];
6790
- return [...log === void 0 ? [] : resolve(log), ...collectSSHFindings(args)];
6791
- }
6792
- if (name === "scp") {
6793
- const destination = collectSSHOperands(args).at(-1);
6794
- if (destination === void 0 || /^[^/]+:/u.test(destination)) return destination === void 0 ? [] : [{
6795
- kind: "remote-write",
6796
- target: "scp"
6797
- }];
6798
- return resolve(destination);
6799
- }
6800
- if (name === "curl") return collectCurlFindings(args);
6801
- const launcher = [name, ...args].findIndex((word) => word.includes("auto-mode"));
6802
- if (launcher !== -1 && [name, ...args].slice(launcher + 1).includes("record")) return [{
6803
- kind: "credential",
6804
- target: "auto-mode record"
6805
- }];
6806
- const finding = findProgramFinding(name, args);
6807
- return finding === null ? [] : [finding];
6808
- }
6809
- function collectCopyFindings(name, args, operands) {
6810
- const optionIndex = args.findIndex((word) => word === "-t" || word === "--target-directory");
6811
- let targetDirectory = args.find((word) => word.startsWith("--target-directory="))?.slice(19);
6812
- if (targetDirectory === void 0 && optionIndex !== -1) targetDirectory = args[optionIndex + 1];
6813
- if (targetDirectory !== void 0) {
6814
- const sources = operands.filter((word) => word !== targetDirectory);
6815
- return [targetDirectory, ...name === "mv" ? sources : []];
6816
- }
6817
- if (operands.length < 2) return [];
6818
- return [operands.at(-1) ?? "", ...name === "mv" ? operands.slice(0, -1) : []];
6819
- }
6820
- const KEYGEN_READ_OPTIONS = /* @__PURE__ */ new Set([
6821
- "-l",
6822
- "-y",
6823
- "-F",
6824
- "-B",
6825
- "-R"
6826
- ]);
6827
- function collectKeygenFiles(args) {
6828
- if (args.some((word) => KEYGEN_READ_OPTIONS.has(word))) return [];
6829
- const index = args.indexOf("-f");
6830
- const file = index === -1 ? void 0 : args[index + 1];
6831
- return file === void 0 ? [] : [file];
6832
- }
6833
- const SSH_VALUE_OPTIONS = /* @__PURE__ */ new Set([
7097
+ //#endregion
7098
+ //#region src/containment/program-table.ts
7099
+ const PATH_OPERANDS = { writes: [{ operands: ["all"] }] };
7100
+ const TARGET_DIRECTORY = ["-t", "--target-directory"];
7101
+ const COPY = {
7102
+ valueOptions: TARGET_DIRECTORY,
7103
+ writes: [{
7104
+ options: TARGET_DIRECTORY,
7105
+ operands: ["destination"]
7106
+ }]
7107
+ };
7108
+ const IN_PLACE_SCRIPT = /^-\w*e$/u;
7109
+ const IN_PLACE_EDIT = {
7110
+ valueOptions: [IN_PLACE_SCRIPT],
7111
+ writes: [{
7112
+ flags: [/^-\w*i/u],
7113
+ operands: ["after-script"],
7114
+ scriptOptions: [IN_PLACE_SCRIPT]
7115
+ }]
7116
+ };
7117
+ const SSH_VALUE_OPTIONS = [
6834
7118
  "-b",
6835
7119
  "-c",
6836
7120
  "-D",
@@ -6840,9 +7124,7 @@ const SSH_VALUE_OPTIONS = /* @__PURE__ */ new Set([
6840
7124
  "-J",
6841
7125
  "-l",
6842
7126
  "-L",
6843
- "-m"
6844
- ]);
6845
- const SSH_MORE_VALUE_OPTIONS = /* @__PURE__ */ new Set([
7127
+ "-m",
6846
7128
  "-o",
6847
7129
  "-p",
6848
7130
  "-P",
@@ -6851,17 +7133,20 @@ const SSH_MORE_VALUE_OPTIONS = /* @__PURE__ */ new Set([
6851
7133
  "-S",
6852
7134
  "-W",
6853
7135
  "-w"
6854
- ]);
6855
- function collectSSHOperands(args) {
6856
- const operands = [];
6857
- for (let index = 0; index < args.length; index += 1) {
6858
- const word = args[index] ?? "";
6859
- if (operands.length > 0 || !word.startsWith("-")) operands.push(word);
6860
- else if (SSH_VALUE_OPTIONS.has(word) || SSH_MORE_VALUE_OPTIONS.has(word)) index += 1;
6861
- }
6862
- return operands;
6863
- }
6864
- const SSH_READS = /* @__PURE__ */ new Set([
7136
+ ];
7137
+ const SCP_VALUE_OPTIONS = [
7138
+ "-c",
7139
+ "-D",
7140
+ "-F",
7141
+ "-i",
7142
+ "-J",
7143
+ "-l",
7144
+ "-o",
7145
+ "-P",
7146
+ "-S",
7147
+ "-X"
7148
+ ];
7149
+ const SSH_READS = [
6865
7150
  "cat",
6866
7151
  "df",
6867
7152
  "du",
@@ -6876,336 +7161,880 @@ const SSH_READS = /* @__PURE__ */ new Set([
6876
7161
  "uname",
6877
7162
  "uptime",
6878
7163
  "whoami"
6879
- ]);
6880
- function collectSSHFindings(args) {
6881
- const [host, ...remote] = collectSSHOperands(args);
6882
- if (host === void 0) return [];
6883
- const command = remote.join(" ");
6884
- const [program] = command.trim().split(/\s+/u);
6885
- return SSH_READS.has(program ?? "") && !/[;&|<>`\n\r]|\$\(/u.test(command) ? [] : [{
7164
+ ];
7165
+ const CURL_UPLOAD = {
7166
+ kind: "remote-write",
7167
+ target: "curl upload"
7168
+ };
7169
+ const CREDENTIAL_WRITE = { writes: [{ finding: { kind: "credential" } }] };
7170
+ const DOCKER_PRUNE = { writes: [{ finding: {
7171
+ kind: "prune",
7172
+ quote: "call"
7173
+ } }] };
7174
+ const IAM_WRITE = /^(?:add|attach|change|create|deactivate|delete|detach|enable|put|remove|reset|set|tag|untag|update|upload)-/u;
7175
+ const KUBECTL_WRITE = { writes: [{ finding: {
7176
+ kind: "remote-write",
7177
+ target: "kubectl write"
7178
+ } }] };
7179
+ const TERRAFORM_WRITE = { writes: [{ finding: {
7180
+ kind: "remote-write",
7181
+ target: "terraform write"
7182
+ } }] };
7183
+ const PRODUCTION_DEPLOY = {
7184
+ words: [/\bdeploy\b/u, /prod/u],
7185
+ finding: {
6886
7186
  kind: "remote-write",
6887
- target: "ssh"
6888
- }];
7187
+ target: "production deploy"
7188
+ }
7189
+ };
7190
+ const CHECKOUT_WRITE = { writes: [{ directory: true }] };
7191
+ const REFSPEC_WRITE = {
7192
+ operands: ["after-first"],
7193
+ role: "refspec"
7194
+ };
7195
+ const GIT = {
7196
+ directoryOptions: ["-C"],
7197
+ valueOptions: ["-c"],
7198
+ subcommands: {
7199
+ clean: CHECKOUT_WRITE,
7200
+ checkout: CHECKOUT_WRITE,
7201
+ switch: CHECKOUT_WRITE,
7202
+ merge: CHECKOUT_WRITE,
7203
+ commit: CHECKOUT_WRITE,
7204
+ rm: CHECKOUT_WRITE,
7205
+ stash: CHECKOUT_WRITE,
7206
+ reset: { writes: [{ directory: true }, {
7207
+ flags: ["--hard"],
7208
+ operands: ["first"],
7209
+ role: "commit",
7210
+ finding: {
7211
+ kind: "discard",
7212
+ target: "git reset --hard"
7213
+ }
7214
+ }] },
7215
+ push: { writes: [
7216
+ { directory: true },
7217
+ {
7218
+ flags: ["--all", "--mirror"],
7219
+ finding: {
7220
+ kind: "branch",
7221
+ target: "--all"
7222
+ }
7223
+ },
7224
+ {
7225
+ operands: ["first"],
7226
+ role: "remote"
7227
+ },
7228
+ {
7229
+ ...REFSPEC_WRITE,
7230
+ readFlags: ["--tags"],
7231
+ whenNone: "HEAD"
7232
+ },
7233
+ {
7234
+ ...REFSPEC_WRITE,
7235
+ flags: ["--tags"]
7236
+ }
7237
+ ] },
7238
+ worktree: { subcommands: { remove: { writes: [{ operands: ["first"] }] } } },
7239
+ branch: { writes: [{
7240
+ flags: [
7241
+ "-d",
7242
+ "-D",
7243
+ "--delete"
7244
+ ],
7245
+ operands: ["all"],
7246
+ role: "branch"
7247
+ }] },
7248
+ config: { writes: [{
7249
+ flags: ["--global"],
7250
+ readFlags: [
7251
+ "--get",
7252
+ "--get-all",
7253
+ "--get-regexp",
7254
+ "--list",
7255
+ "-l"
7256
+ ],
7257
+ finding: {
7258
+ kind: "credential",
7259
+ quote: "operand"
7260
+ }
7261
+ }] }
7262
+ }
7263
+ };
7264
+ const GH_REPO_OPTIONS = ["-R", "--repo"];
7265
+ const PULL_REQUEST_WRITE = {
7266
+ operands: ["first"],
7267
+ role: "pull-request",
7268
+ repoOptions: GH_REPO_OPTIONS,
7269
+ finding: { kind: "remote-write" }
7270
+ };
7271
+ const GH_PULL_REQUEST = { subcommands: {
7272
+ comment: {
7273
+ valueOptions: [
7274
+ ...GH_REPO_OPTIONS,
7275
+ "-b",
7276
+ "--body",
7277
+ "-F",
7278
+ "--body-file",
7279
+ "--attach"
7280
+ ],
7281
+ writes: [PULL_REQUEST_WRITE]
7282
+ },
7283
+ merge: {
7284
+ valueOptions: [
7285
+ ...GH_REPO_OPTIONS,
7286
+ "-A",
7287
+ "--author-email",
7288
+ "-b",
7289
+ "--body",
7290
+ "-F",
7291
+ "--body-file",
7292
+ "--match-head-commit",
7293
+ "-t",
7294
+ "--subject"
7295
+ ],
7296
+ writes: [{
7297
+ ...PULL_REQUEST_WRITE,
7298
+ role: "pull-request-merge",
7299
+ ungrantFlags: [/^--admin(?:=|$)/u]
7300
+ }]
7301
+ },
7302
+ edit: {
7303
+ valueOptions: [
7304
+ ...GH_REPO_OPTIONS,
7305
+ "--add-assignee",
7306
+ "--add-label",
7307
+ "--add-project",
7308
+ "--add-reviewer",
7309
+ "--attach",
7310
+ "-B",
7311
+ "--base",
7312
+ "-b",
7313
+ "--body",
7314
+ "-F",
7315
+ "--body-file",
7316
+ "-m",
7317
+ "--milestone",
7318
+ "--remove-assignee",
7319
+ "--remove-label",
7320
+ "--remove-project",
7321
+ "--remove-reviewer",
7322
+ "-t",
7323
+ "--title"
7324
+ ],
7325
+ writes: [PULL_REQUEST_WRITE]
7326
+ },
7327
+ close: {
7328
+ valueOptions: [
7329
+ ...GH_REPO_OPTIONS,
7330
+ "-c",
7331
+ "--comment"
7332
+ ],
7333
+ writes: [PULL_REQUEST_WRITE]
7334
+ },
7335
+ review: {
7336
+ valueOptions: [
7337
+ ...GH_REPO_OPTIONS,
7338
+ "-b",
7339
+ "--body",
7340
+ "-F",
7341
+ "--body-file"
7342
+ ],
7343
+ writes: [PULL_REQUEST_WRITE]
7344
+ },
7345
+ ready: {
7346
+ valueOptions: GH_REPO_OPTIONS,
7347
+ writes: [PULL_REQUEST_WRITE]
7348
+ },
7349
+ reopen: {
7350
+ valueOptions: [
7351
+ ...GH_REPO_OPTIONS,
7352
+ "-c",
7353
+ "--comment"
7354
+ ],
7355
+ writes: [PULL_REQUEST_WRITE]
7356
+ }
7357
+ } };
7358
+ const GH_API_FIELDS = [
7359
+ "-f",
7360
+ "-F",
7361
+ "--field",
7362
+ "--raw-field",
7363
+ "--input"
7364
+ ];
7365
+ const FORGE_WRITE = {
7366
+ operands: ["first"],
7367
+ role: "forge-path",
7368
+ credentialPattern: /secret|collaborator|protection|key|permission/u,
7369
+ finding: { kind: "remote-write" }
7370
+ };
7371
+ const GH_API = {
7372
+ valueOptions: [
7373
+ ...GH_API_FIELDS,
7374
+ "-X",
7375
+ "--method",
7376
+ "-H",
7377
+ "--header",
7378
+ "-q",
7379
+ "--jq",
7380
+ "-t",
7381
+ "--template",
7382
+ "--hostname",
7383
+ "-p",
7384
+ "--preview",
7385
+ "--cache"
7386
+ ],
7387
+ subcommands: { graphql: { writes: [{
7388
+ words: [/\bmutation\b/u],
7389
+ finding: {
7390
+ kind: "remote-write",
7391
+ target: "gh api graphql mutation"
7392
+ }
7393
+ }] } },
7394
+ writes: [{
7395
+ ...FORGE_WRITE,
7396
+ optionValue: {
7397
+ options: ["-X", "--method"],
7398
+ value: /^(?:POST|PUT|PATCH|DELETE)$/iu
7399
+ }
7400
+ }, {
7401
+ ...FORGE_WRITE,
7402
+ flags: GH_API_FIELDS,
7403
+ readFlags: ["-X", "--method"]
7404
+ }]
7405
+ };
7406
+ const GH_READS = {
7407
+ list: {},
7408
+ view: {},
7409
+ clone: {},
7410
+ status: {},
7411
+ checks: {},
7412
+ diff: {}
7413
+ };
7414
+ const GH_REMOTE_GROUP = {
7415
+ subcommands: GH_READS,
7416
+ otherSubcommand: { entry: { writes: [{ finding: { kind: "remote-write" } }] } }
7417
+ };
7418
+ const GH_CREDENTIAL_GROUP = {
7419
+ subcommands: GH_READS,
7420
+ otherSubcommand: { entry: CREDENTIAL_WRITE }
7421
+ };
7422
+ const PROGRAM_TABLE = {
7423
+ git: GIT,
7424
+ gh: { subcommands: {
7425
+ pr: GH_PULL_REQUEST,
7426
+ issue: GH_REMOTE_GROUP,
7427
+ api: GH_API,
7428
+ auth: { subcommands: {
7429
+ refresh: CREDENTIAL_WRITE,
7430
+ login: CREDENTIAL_WRITE,
7431
+ logout: CREDENTIAL_WRITE
7432
+ } },
7433
+ secret: GH_CREDENTIAL_GROUP,
7434
+ variable: GH_CREDENTIAL_GROUP,
7435
+ "ssh-key": GH_CREDENTIAL_GROUP,
7436
+ "gpg-key": GH_CREDENTIAL_GROUP,
7437
+ gist: GH_REMOTE_GROUP,
7438
+ release: GH_REMOTE_GROUP,
7439
+ repo: GH_REMOTE_GROUP,
7440
+ ruleset: GH_REMOTE_GROUP,
7441
+ label: GH_REMOTE_GROUP,
7442
+ workflow: GH_REMOTE_GROUP
7443
+ } },
7444
+ rm: PATH_OPERANDS,
7445
+ rmdir: PATH_OPERANDS,
7446
+ shred: PATH_OPERANDS,
7447
+ truncate: PATH_OPERANDS,
7448
+ chmod: PATH_OPERANDS,
7449
+ chown: PATH_OPERANDS,
7450
+ cp: COPY,
7451
+ ln: COPY,
7452
+ mv: {
7453
+ valueOptions: TARGET_DIRECTORY,
7454
+ writes: [{
7455
+ options: TARGET_DIRECTORY,
7456
+ operands: ["destination", "sources"]
7457
+ }]
7458
+ },
7459
+ rsync: { writes: [{ operands: ["destination"] }] },
7460
+ find: { writes: [{
7461
+ flags: ["-delete", "-exec"],
7462
+ operands: ["first"]
7463
+ }] },
7464
+ sed: IN_PLACE_EDIT,
7465
+ perl: IN_PLACE_EDIT,
7466
+ "ssh-keygen": {
7467
+ valueOptions: ["-f"],
7468
+ writes: [{
7469
+ readFlags: [
7470
+ "-l",
7471
+ "-y",
7472
+ "-F",
7473
+ "-B",
7474
+ "-R"
7475
+ ],
7476
+ options: ["-f"]
7477
+ }]
7478
+ },
7479
+ ssh: {
7480
+ valueOptions: SSH_VALUE_OPTIONS,
7481
+ optionsEndAtOperand: true,
7482
+ writes: [{ options: ["-E"] }, {
7483
+ operands: ["all"],
7484
+ role: "remote-command",
7485
+ reads: SSH_READS,
7486
+ finding: { kind: "remote-write" }
7487
+ }]
7488
+ },
7489
+ scp: {
7490
+ valueOptions: SCP_VALUE_OPTIONS,
7491
+ optionsEndAtOperand: true,
7492
+ writes: [{
7493
+ operands: ["destination"],
7494
+ role: "remote-path",
7495
+ finding: { kind: "remote-write" }
7496
+ }]
7497
+ },
7498
+ curl: {
7499
+ valueOptions: ["-X", "--request"],
7500
+ writes: [{
7501
+ flags: [/^-[a-zA-Z]*[dFT]$/u, /^--(?:data\S*|form|upload-file|json)$/u],
7502
+ operands: ["all"],
7503
+ role: "url",
7504
+ finding: CURL_UPLOAD
7505
+ }, {
7506
+ optionValue: {
7507
+ options: ["-X", "--request"],
7508
+ value: /^(?:POST|PUT|PATCH|DELETE)$/u
7509
+ },
7510
+ operands: ["all"],
7511
+ role: "url",
7512
+ finding: CURL_UPLOAD
7513
+ }]
7514
+ },
7515
+ op: { subcommands: {
7516
+ item: { subcommands: {
7517
+ edit: CREDENTIAL_WRITE,
7518
+ create: CREDENTIAL_WRITE,
7519
+ delete: CREDENTIAL_WRITE
7520
+ } },
7521
+ vault: { subcommands: {
7522
+ edit: CREDENTIAL_WRITE,
7523
+ create: CREDENTIAL_WRITE,
7524
+ delete: CREDENTIAL_WRITE
7525
+ } },
7526
+ document: { subcommands: {
7527
+ edit: CREDENTIAL_WRITE,
7528
+ create: CREDENTIAL_WRITE,
7529
+ delete: CREDENTIAL_WRITE
7530
+ } }
7531
+ } },
7532
+ aws: { subcommands: { iam: { otherSubcommand: {
7533
+ pattern: IAM_WRITE,
7534
+ entry: CREDENTIAL_WRITE
7535
+ } } } },
7536
+ docker: {
7537
+ valueOptions: [
7538
+ "--config",
7539
+ "-c",
7540
+ "--context",
7541
+ "-H",
7542
+ "--host",
7543
+ "-l",
7544
+ "--log-level",
7545
+ "--tlscacert",
7546
+ "--tlscert",
7547
+ "--tlskey"
7548
+ ],
7549
+ subcommands: {
7550
+ volume: { subcommands: {
7551
+ prune: DOCKER_PRUNE,
7552
+ rm: DOCKER_PRUNE
7553
+ } },
7554
+ system: { subcommands: { prune: DOCKER_PRUNE } },
7555
+ image: { subcommands: { prune: DOCKER_PRUNE } },
7556
+ container: { subcommands: { prune: DOCKER_PRUNE } },
7557
+ compose: {
7558
+ valueOptions: [
7559
+ "--ansi",
7560
+ "--env-file",
7561
+ "-f",
7562
+ "--file",
7563
+ "--parallel",
7564
+ "--profile",
7565
+ "--progress",
7566
+ "--project-directory",
7567
+ "-p",
7568
+ "--project-name"
7569
+ ],
7570
+ subcommands: { down: { writes: [{
7571
+ flags: ["-v", "--volumes"],
7572
+ finding: {
7573
+ kind: "prune",
7574
+ quote: "call"
7575
+ }
7576
+ }] } }
7577
+ }
7578
+ }
7579
+ },
7580
+ kubectl: {
7581
+ valueOptions: [
7582
+ "--as",
7583
+ "--as-group",
7584
+ "--cache-dir",
7585
+ "--certificate-authority",
7586
+ "--client-certificate",
7587
+ "--client-key",
7588
+ "--cluster",
7589
+ "--context",
7590
+ "--kubeconfig",
7591
+ "-n",
7592
+ "--namespace",
7593
+ "--request-timeout",
7594
+ "-s",
7595
+ "--server",
7596
+ "--tls-server-name",
7597
+ "--token",
7598
+ "--user"
7599
+ ],
7600
+ subcommands: {
7601
+ apply: KUBECTL_WRITE,
7602
+ delete: KUBECTL_WRITE,
7603
+ replace: KUBECTL_WRITE,
7604
+ patch: KUBECTL_WRITE,
7605
+ scale: KUBECTL_WRITE
7606
+ }
7607
+ },
7608
+ terraform: { subcommands: {
7609
+ apply: TERRAFORM_WRITE,
7610
+ destroy: TERRAFORM_WRITE,
7611
+ import: TERRAFORM_WRITE
7612
+ } },
7613
+ codex: { subcommands: { exec: {
7614
+ valueOptions: ["-o", "--output-last-message"],
7615
+ writes: [{ options: ["-o", "--output-last-message"] }]
7616
+ } } },
7617
+ psql: { writes: [{
7618
+ words: [/\b(?:UPDATE|DELETE|DROP|INSERT|TRUNCATE|ALTER)\b/iu],
7619
+ finding: {
7620
+ kind: "remote-write",
7621
+ target: "database write"
7622
+ }
7623
+ }] },
7624
+ npm: {
7625
+ subcommands: { publish: { writes: [{ finding: { kind: "remote-write" } }] } },
7626
+ writes: [PRODUCTION_DEPLOY]
7627
+ },
7628
+ bun: { writes: [PRODUCTION_DEPLOY] },
7629
+ pnpm: { writes: [PRODUCTION_DEPLOY] },
7630
+ yarn: { writes: [PRODUCTION_DEPLOY] }
7631
+ };
7632
+ //#endregion
7633
+ //#region src/containment/collect-program-findings.ts
7634
+ function collectProgramFindings(name, args, context, table = PROGRAM_TABLE) {
7635
+ const entry = findEntry(table, name);
7636
+ return entry === void 0 ? [] : collectEntryFindings(entry, [name], args, {
7637
+ ...context,
7638
+ call: [name, ...args]
7639
+ });
6889
7640
  }
6890
- const LOCAL_HOSTS = /* @__PURE__ */ new Set([
6891
- "localhost",
6892
- "127.0.0.1",
6893
- "[::1]",
6894
- "0.0.0.0"
6895
- ]);
6896
- function collectCurlFindings(args) {
6897
- const joined = args.join(" ");
6898
- const isUpload = args.some((word) => /^-[a-zA-Z]*[dFT]$|^--(?:data\S*|form|upload-file|json)$/u.test(word));
6899
- const isWriteMethod = /(?:-X\s*|--request\s+)(?:POST|PUT|PATCH|DELETE)/u.test(joined);
6900
- if (!isUpload && !isWriteMethod) return [];
6901
- const hosts = args.filter((word) => /^(?:https?:\/\/|localhost\b|127\.)/u.test(word)).map((word) => /^(?:https?:\/\/)?(?<host>\[[^\]]+\]|[^/:?#\s]+)/u.exec(word)?.groups?.["host"]?.toLowerCase() ?? "");
6902
- return hosts.length > 0 && hosts.every((host) => LOCAL_HOSTS.has(host)) ? [] : [{
6903
- kind: "remote-write",
6904
- target: "curl upload"
6905
- }];
7641
+ function findEntry(entries, name) {
7642
+ return entries !== void 0 && Object.hasOwn(entries, name) ? entries[name] : void 0;
6906
7643
  }
6907
- const GIT_DIRECTORY_WRITES = /* @__PURE__ */ new Set([
6908
- "clean",
6909
- "reset",
6910
- "checkout",
6911
- "switch",
6912
- "merge",
6913
- "commit",
6914
- "rm",
6915
- "stash",
6916
- "push"
6917
- ]);
6918
- function collectGitFindings(args, directory, cwd, scope, scratchPaths) {
6919
- const git = splitGitCommand(args, directory, scope.home);
6920
- const gitDirectory = git.directory;
6921
- const [subcommand = "", ...rest] = git.args;
6922
- const operands = rest.filter((word) => !word.startsWith("-"));
6923
- const findings = [];
6924
- if (gitDirectory !== null && GIT_DIRECTORY_WRITES.has(subcommand) && !isInScope(gitDirectory, scope, scratchPaths)) findings.push({
7644
+ function collectEntryFindings(entry, command, words, context) {
7645
+ const call = splitProgramCall(entry, words, context.directory, context.scope.home);
7646
+ const entryContext = {
7647
+ ...context,
7648
+ directory: call.directory
7649
+ };
7650
+ if (call.subcommand !== null) return collectEntryFindings(call.subcommand.entry, [...command, call.subcommand.name], call.subcommand.words, entryContext);
7651
+ return (entry.writes ?? []).filter((rule) => isRuleApplied(rule, call, context.call)).flatMap((rule) => collectRuleFindings(rule, call, command, entryContext));
7652
+ }
7653
+ function splitProgramCall(entry, words, start, home) {
7654
+ const options = [];
7655
+ const operands = [];
7656
+ const directoryOptions = entry.directoryOptions ?? [];
7657
+ let directory = start;
7658
+ let isOptionsEnd = false;
7659
+ for (let index = 0; index < words.length; index += 1) {
7660
+ const word = words[index] ?? "";
7661
+ if (isOptionsEnd || !word.startsWith("-") || word === "-") {
7662
+ const subcommand = operands.length === 0 ? findSubcommand(entry, word) : void 0;
7663
+ if (subcommand !== void 0) return {
7664
+ options,
7665
+ operands,
7666
+ directory,
7667
+ subcommand: {
7668
+ name: word,
7669
+ entry: subcommand,
7670
+ words: words.slice(index + 1)
7671
+ }
7672
+ };
7673
+ operands.push(word);
7674
+ isOptionsEnd ||= entry.optionsEndAtOperand === true;
7675
+ } else if (word === "--") isOptionsEnd = true;
7676
+ else {
7677
+ const split = splitOption([...directoryOptions, ...entry.valueOptions ?? []], word, words[index + 1]);
7678
+ for (const option of split.options) if (directoryOptions.includes(option.name)) directory = resolveShellPath(directory, option.value ?? "", home);
7679
+ options.push(...split.options);
7680
+ index += split.isValueNext ? 1 : 0;
7681
+ }
7682
+ }
7683
+ return {
7684
+ options,
7685
+ operands,
7686
+ directory,
7687
+ subcommand: null
7688
+ };
7689
+ }
7690
+ function findSubcommand(entry, word) {
7691
+ const other = entry.otherSubcommand;
7692
+ return findEntry(entry.subcommands, word) ?? (other !== void 0 && (other.pattern?.test(word) ?? true) ? other.entry : void 0);
7693
+ }
7694
+ function splitOption(valueOptions, word, next) {
7695
+ for (const pattern of valueOptions) if (typeof pattern !== "string") {
7696
+ if (pattern.test(word)) return {
7697
+ options: [{
7698
+ name: word,
7699
+ value: next
7700
+ }],
7701
+ isValueNext: true
7702
+ };
7703
+ } else if (word === pattern) return {
7704
+ options: [{
7705
+ name: pattern,
7706
+ value: next
7707
+ }],
7708
+ isValueNext: true
7709
+ };
7710
+ else if (pattern.startsWith("--") ? word.startsWith(`${pattern}=`) : word.startsWith(pattern)) return {
7711
+ options: [{
7712
+ name: pattern,
7713
+ value: word.slice(pattern.length).replace(/^=/u, "")
7714
+ }],
7715
+ isValueNext: false
7716
+ };
7717
+ return splitOptionCluster(valueOptions, word, next);
7718
+ }
7719
+ function splitOptionCluster(valueOptions, word, next) {
7720
+ const letters = /^-[^-]./u.test(word) ? word.slice(1).match(/./gu) ?? [] : [];
7721
+ const at = letters.findIndex((letter) => valueOptions.includes(`-${letter}`));
7722
+ if (at === -1) return {
7723
+ options: [{
7724
+ name: word,
7725
+ value: void 0
7726
+ }],
7727
+ isValueNext: false
7728
+ };
7729
+ const attached = word.slice(at + 2);
7730
+ return {
7731
+ options: [...letters.slice(0, at).map((letter) => ({
7732
+ name: `-${letter}`,
7733
+ value: void 0
7734
+ })), {
7735
+ name: `-${letters[at] ?? ""}`,
7736
+ value: attached === "" ? next : attached
7737
+ }],
7738
+ isValueNext: attached === ""
7739
+ };
7740
+ }
7741
+ function isRuleApplied(rule, call, words) {
7742
+ const optionValue = rule.optionValue;
7743
+ return (rule.flags === void 0 || hasOption(call, rule.flags)) && !hasOption(call, rule.readFlags ?? []) && (rule.words ?? []).every((pattern) => words.some((word) => pattern.test(word))) && (optionValue === void 0 || call.options.some((option) => optionValue.options.includes(option.name) && option.value !== void 0 && optionValue.value.test(option.value)));
7744
+ }
7745
+ function hasOption(call, patterns) {
7746
+ return call.options.some((option) => patterns.some((pattern) => typeof pattern === "string" ? option.name === pattern : pattern.test(option.name)));
7747
+ }
7748
+ function collectRuleFindings(rule, call, command, context) {
7749
+ const finding = rule.finding === void 0 ? null : buildFinding(rule.finding, command, call, context.call);
7750
+ const directory = context.directory;
7751
+ if (rule.directory === true) return directory === null || isInScope(directory, context.scope, context.scratchPaths) ? [] : [{
6925
7752
  kind: "path",
6926
- target: gitDirectory
6927
- });
6928
- if (subcommand === "worktree" && operands[0] === "remove" && operands[1] !== void 0) findings.push(...collectTargetFindings(gitDirectory, operands[1], scope, scratchPaths));
6929
- if (subcommand === "branch" && rest.some((word) => /^-(?:d|D|-delete)$/u.test(word))) for (const branch of operands.filter((word) => !scope.branches.includes(word))) findings.push({
7753
+ target: directory
7754
+ }];
7755
+ if (rule.options === void 0 && rule.operands === void 0) return finding === null ? [] : [finding];
7756
+ const targets = [...call.options.flatMap((option) => rule.options?.includes(option.name) === true && option.value !== void 0 ? [option.value] : []), ...(rule.operands ?? []).flatMap((selector) => pickOperands(selector, rule, call))];
7757
+ return collectRoleFindings(rule, targets.length === 0 && rule.whenNone !== void 0 ? [rule.whenNone] : targets, finding, call, context);
7758
+ }
7759
+ function buildFinding(finding, command, call, words) {
7760
+ const quoted = {
7761
+ call: words,
7762
+ operand: [...command, ...call.operands.slice(0, 1)],
7763
+ command
7764
+ };
7765
+ return {
7766
+ kind: finding.kind,
7767
+ target: finding.target ?? quoted[finding.quote ?? "command"].join(" ")
7768
+ };
7769
+ }
7770
+ function collectRoleFindings(rule, targets, finding, call, context) {
7771
+ const scope = context.scope;
7772
+ const resolve = (target) => collectTargetFindings(context.directory, target, scope, context.scratchPaths);
7773
+ const role = rule.role ?? "path";
7774
+ if (role === "path") return targets.flatMap((target) => resolve(target));
7775
+ if (role === "branch") return targets.filter((branch) => !scope.branches.includes(branch)).map((branch) => ({
6930
7776
  kind: "branch",
6931
7777
  target: branch,
6932
7778
  grant: { kind: "branch" }
6933
- });
6934
- const [resetTarget] = operands;
6935
- if (subcommand === "reset" && rest.includes("--hard") && resetTarget !== void 0 && resetTarget !== "HEAD" && resetTarget !== "@" && !isUnresolvedWord(resetTarget)) findings.push({
6936
- kind: "discard",
6937
- target: `git reset --hard ${resetTarget}`
6938
- });
6939
- const isConfigRead = rest.some((word) => /^--(?:get|get-all|get-regexp|list)$|^-l$/u.test(word));
6940
- if (subcommand === "config" && rest.includes("--global") && !isConfigRead) findings.push({
6941
- kind: "credential",
6942
- target: `git config ${operands[0] ?? ""}`
6943
- });
6944
- if (subcommand === "push") {
6945
- const currentBranch = gitDirectory === cwd ? scope.currentBranch : null;
6946
- findings.push(...collectPushFindings(rest, operands, currentBranch, scope));
6947
- }
6948
- return findings;
6949
- }
6950
- function collectPushFindings(rest, operands, currentBranch, scope) {
6951
- const [remote, ...refspecs] = operands;
6952
- const findings = [];
6953
- if (rest.includes("--all") || rest.includes("--mirror")) findings.push({
6954
- kind: "branch",
6955
- target: "--all"
6956
- });
6957
- if (remote !== void 0 && !isUnresolvedWord(remote) && !isOwnedRemote(remote, scope)) findings.push({
7779
+ }));
7780
+ if (role === "remote") return targets.filter((remote) => !isUnresolvedWord(remote) && !isOwnedRemote(remote, scope)).map((remote) => ({
6958
7781
  kind: "remote-write",
6959
7782
  target: remote
6960
- });
6961
- const destinations = refspecs.length === 0 && !rest.includes("--tags") ? ["HEAD"] : refspecs;
6962
- for (const refspec of destinations) {
6963
- const named = (refspec.split(":").at(-1) ?? refspec).replace(/^\+/u, "").replace(/^refs\/heads\//u, "");
6964
- const branch = named === "HEAD" ? currentBranch : named;
6965
- if (branch !== null && !isUnresolvedWord(branch) && !branch.startsWith("refs/tags/") && !scope.branches.includes(branch)) findings.push({
6966
- kind: "branch",
6967
- target: branch,
6968
- grant: { kind: "branch" }
6969
- });
7783
+ }));
7784
+ if (role === "refspec") {
7785
+ const current = context.directory === context.cwd ? scope.currentBranch : null;
7786
+ return targets.flatMap((refspec) => collectRefspecFindings(refspec, current, scope));
6970
7787
  }
6971
- return findings;
7788
+ return finding === null ? [] : collectNamedFindings(role, rule, targets, finding, call, context);
6972
7789
  }
6973
7790
  function isOwnedRemote(remote, scope) {
6974
7791
  if (scope.remotes.some((entry) => entry.name === remote)) return true;
6975
7792
  const slug = toRepositorySlug(remote);
6976
7793
  return slug !== null && scope.remotes.some((entry) => toRepositorySlug(entry.url) === slug);
6977
7794
  }
6978
- const GH_PR_WRITES = /* @__PURE__ */ new Set([
6979
- "comment",
6980
- "merge",
6981
- "edit",
6982
- "close",
6983
- "review",
6984
- "ready",
6985
- "reopen"
6986
- ]);
6987
- const GH_REMOTE_WRITES = /* @__PURE__ */ new Set([
6988
- "gist",
6989
- "release",
6990
- "repo",
6991
- "ruleset",
6992
- "label",
6993
- "workflow"
6994
- ]);
6995
- const GH_CREDENTIAL_WRITES = /* @__PURE__ */ new Set([
6996
- "secret",
6997
- "variable",
6998
- "ssh-key",
6999
- "gpg-key"
7795
+ function collectRefspecFindings(refspec, current, scope) {
7796
+ const named = (refspec.split(":").at(-1) ?? refspec).replace(/^\+/u, "").replace(/^refs\/heads\//u, "");
7797
+ const branch = named === "HEAD" ? current : named;
7798
+ return branch === null || isUnresolvedWord(branch) || branch.startsWith("refs/tags/") || scope.branches.includes(branch) ? [] : [{
7799
+ kind: "branch",
7800
+ target: branch,
7801
+ grant: { kind: "branch" }
7802
+ }];
7803
+ }
7804
+ function collectNamedFindings(role, rule, targets, finding, call, context) {
7805
+ if (role === "remote-path") return targets.flatMap((target) => /^[^/]+:/u.test(target) ? [finding] : collectTargetFindings(context.directory, target, context.scope, context.scratchPaths));
7806
+ if (role === "url") return isLocalUpload(targets) ? [] : [finding];
7807
+ if (role === "remote-command") return isRemoteRead(targets, rule.reads ?? []) ? [] : [finding];
7808
+ if (role === "commit") return targets.filter((commit) => commit !== "HEAD" && commit !== "@" && !isUnresolvedWord(commit)).map((commit) => ({
7809
+ kind: finding.kind,
7810
+ target: `${finding.target} ${commit}`
7811
+ }));
7812
+ if (role === "pull-request" || role === "pull-request-merge") return collectPullRequestFindings(rule, targets[0], finding, call, context.scope);
7813
+ return collectForgeFindings(rule, targets[0] ?? "", finding, context.scope);
7814
+ }
7815
+ function collectPullRequestFindings(rule, selector, finding, call, scope) {
7816
+ const repoOptions = rule.repoOptions ?? [];
7817
+ const url = /^https?:\/\/(?<repo>[^/]+\/[^/]+\/[^/]+)\/pull\/(?<number>\d+)(?:[/?#]|$)/u.exec(selector ?? "")?.groups;
7818
+ const repo = url?.["repo"] ?? call.options.findLast((option) => repoOptions.includes(option.name))?.value;
7819
+ let number = NaN;
7820
+ if (url !== void 0) number = Number(url["number"]);
7821
+ else if (selector !== void 0 && /^#?\d+$/u.test(selector)) number = Number(selector.replace("#", ""));
7822
+ if (rule.role === "pull-request" && isOwnedPullRequest(number, repo, scope)) return [];
7823
+ return Number.isInteger(number) && !hasOption(call, rule.ungrantFlags ?? []) ? [{
7824
+ ...finding,
7825
+ grant: {
7826
+ kind: "pull-request",
7827
+ number
7828
+ }
7829
+ }] : [finding];
7830
+ }
7831
+ function collectForgeFindings(rule, path, finding, scope) {
7832
+ const pull = /^\/?repos\/(?<repo>[^/]+\/[^/]+)\/(?:pulls|issues)\/(?<number>\d+)(?:\/|$)/u.exec(path)?.groups;
7833
+ if (pull !== void 0 && isOwnedPullRequest(Number(pull["number"]), pull["repo"] ?? "", scope) && !path.includes("/merge")) return [];
7834
+ return [{
7835
+ kind: rule.credentialPattern?.test(path) === true ? "credential" : finding.kind,
7836
+ target: `${finding.target} ${path}`
7837
+ }];
7838
+ }
7839
+ function pickOperands(selector, rule, call) {
7840
+ const operands = call.operands;
7841
+ const isNamed = hasOption(call, rule.options ?? []);
7842
+ if (selector === "all") return operands;
7843
+ if (selector === "first") return operands.slice(0, 1);
7844
+ if (selector === "after-first") return operands.slice(1);
7845
+ if (selector === "after-script") return hasOption(call, rule.scriptOptions ?? []) ? operands : operands.slice(1);
7846
+ if (isNamed) return selector === "sources" ? operands : [];
7847
+ if (operands.length < 2) return [];
7848
+ return selector === "destination" ? operands.slice(-1) : operands.slice(0, -1);
7849
+ }
7850
+ const LOCAL_HOSTS = /* @__PURE__ */ new Set([
7851
+ "localhost",
7852
+ "127.0.0.1",
7853
+ "[::1]",
7854
+ "0.0.0.0"
7000
7855
  ]);
7001
- const GH_READS = /* @__PURE__ */ new Set([
7002
- "list",
7003
- "view",
7004
- "clone",
7005
- "status",
7006
- "checks",
7007
- "diff"
7856
+ function isLocalUpload(words) {
7857
+ const hosts = words.filter((word) => /^(?:https?:\/\/|localhost\b|127\.)/u.test(word)).map((word) => /^(?:https?:\/\/)?(?<host>\[[^\]]+\]|[^/:?#\s]+)/u.exec(word)?.groups?.["host"]?.toLowerCase() ?? "");
7858
+ return hosts.length > 0 && hosts.every((host) => LOCAL_HOSTS.has(host));
7859
+ }
7860
+ function isRemoteRead(words, reads) {
7861
+ const [host, ...remote] = words;
7862
+ if (host === void 0) return true;
7863
+ const command = remote.join(" ");
7864
+ const [program] = command.trim().split(/\s+/u);
7865
+ return reads.includes(program ?? "") && !/[;&|<>`\n\r]|\$\(/u.test(command);
7866
+ }
7867
+ //#endregion
7868
+ //#region src/containment/collect-scope-findings.ts
7869
+ function collectScopeFindings(action, scope, scratchPaths = SCRATCH_PATHS) {
7870
+ const filePath = action.input["file_path"] ?? action.input["notebook_path"];
7871
+ const command = action.input["command"];
7872
+ const findings = [
7873
+ ...FILE_WRITE_TOOLS.has(action.tool) && typeof filePath === "string" ? collectTargetFindings(action.cwd, filePath, scope, scratchPaths) : [],
7874
+ ...action.tool.startsWith("mcp__") ? collectToolFindings(action, scope) : [],
7875
+ ...action.tool === "Bash" && typeof command === "string" ? collectCommandFindings(command, action.cwd, action.cwd, scope, scratchPaths) : []
7876
+ ];
7877
+ return findings.filter((finding, index) => findings.findIndex((other) => other.kind === finding.kind && other.target === finding.target) === index);
7878
+ }
7879
+ const FILE_WRITE_TOOLS = /* @__PURE__ */ new Set([
7880
+ "Edit",
7881
+ "MultiEdit",
7882
+ "Write",
7883
+ "NotebookEdit"
7008
7884
  ]);
7009
- function collectGhFindings(args, scope) {
7010
- const group = args[0] ?? "";
7011
- const verb = args[1] ?? "";
7012
- if (group === "pr" && GH_PR_WRITES.has(verb)) {
7013
- const pull = findPullRequestSelector(verb, args.slice(2));
7014
- const number = pull?.number ?? NaN;
7015
- const isOwned = verb !== "merge" && isOwnedPullRequest(number, pull?.repo, scope);
7016
- const isAdmin = args.some((word) => /^--admin(?:=|$)/u.test(word));
7017
- if (isOwned) return [];
7018
- return Number.isInteger(number) && !isAdmin ? [{
7019
- kind: "remote-write",
7020
- target: `gh pr ${verb}`,
7021
- grant: {
7022
- kind: "pull-request",
7023
- number
7024
- }
7025
- }] : [{
7026
- kind: "remote-write",
7027
- target: `gh pr ${verb}`
7028
- }];
7885
+ function collectToolFindings(action, scope) {
7886
+ const verb = action.tool.split("__").at(-1) ?? "";
7887
+ if (!/merge|create|comment|update|delete|push|close|review/u.test(verb)) return [];
7888
+ const number = action.input["pullNumber"] ?? action.input["pull_number"] ?? action.input["issue_number"];
7889
+ const owner = action.input["owner"];
7890
+ const name = action.input["repo"];
7891
+ const repo = typeof owner === "string" && typeof name === "string" ? `${owner}/${name}` : void 0;
7892
+ if (!verb.includes("merge") && typeof number === "number" && isOwnedPullRequest(number, repo, scope)) return [];
7893
+ return typeof number === "number" ? [{
7894
+ kind: "remote-write",
7895
+ target: action.tool,
7896
+ grant: {
7897
+ kind: "pull-request",
7898
+ number
7899
+ }
7900
+ }] : [{
7901
+ kind: "remote-write",
7902
+ target: action.tool
7903
+ }];
7904
+ }
7905
+ function collectCommandFindings(command, directory, cwd, scope, scratchPaths) {
7906
+ const findings = [];
7907
+ const entries = collectShellCommands(command, directory, scope.home);
7908
+ const piped = collectPipedSegments(command);
7909
+ const segments = splitShellCommand(command).segments;
7910
+ const scripts = collectShellScripts(command);
7911
+ for (const [index, entry] of entries.entries()) {
7912
+ const [name, ...args] = entry.words;
7913
+ const fixed = findFixedXargsCommand(entry.words, segments[index]?.text ?? "");
7914
+ if (fixed !== null) findings.push(...collectCallFindings(fixed.name, fixed.args, entry.directory, cwd, scope, scratchPaths));
7915
+ else if (isBranchSweep(entries.slice(0, index + 1), piped.slice(0, index + 1))) findings.push({
7916
+ kind: "discard",
7917
+ target: "git branch delete by exclusion"
7918
+ });
7919
+ for (const target of entry.writeTargets) findings.push(...collectTargetFindings(entry.directory, target, scope, scratchPaths));
7920
+ if (name !== void 0 && name !== "cd") findings.push(...collectCallFindings(name, args, entry.directory, cwd, scope, scratchPaths));
7921
+ for (const script of scripts.filter((candidate) => candidate.segment === index)) findings.push(...collectCommandFindings(script.text, entry.directory, cwd, scope, scratchPaths));
7922
+ }
7923
+ return findings;
7924
+ }
7925
+ function collectPipedSegments(command) {
7926
+ const piped = [];
7927
+ let separator = null;
7928
+ let isInSegment = false;
7929
+ for (const token of splitShellTokens(command).tokens) if (token.kind === "separator") {
7930
+ separator = token.operator;
7931
+ isInSegment = false;
7932
+ } else if (!isInSegment && token.kind !== "comment" && token.kind !== "heredoc") {
7933
+ piped.push(separator === "|");
7934
+ isInSegment = true;
7935
+ }
7936
+ return piped;
7937
+ }
7938
+ const XARGS_FLAG = /^(?:-[0oprtx]+|--(?:null|no-run-if-empty|verbose|interactive|exit|open-tty))$/u;
7939
+ const XARGS_VALUE_OPTION = /^-[EPads]$/u;
7940
+ const XARGS_ATTACHED_VALUE = /^(?:-[EPads].|--(?:eof|max-procs|delimiter|arg-file|max-chars|process-slot-var)=)/u;
7941
+ const XARGS_REPLACE = /^(?:-I(?<attached>.+)|-i(?<short>.*)|--replace(?:=(?<long>.*))?)$/u;
7942
+ const PLAIN_WORD = /^(?:[\w./:@%+=-]|\{\})+$/u;
7943
+ function findFixedXargsCommand(words, text) {
7944
+ if (words[0] !== "xargs" || !splitShellTokens(text).tokens.every((token) => token.kind !== "word" || token.raw === token.value && PLAIN_WORD.test(token.value))) return null;
7945
+ let token = null;
7946
+ let start = 1;
7947
+ while (words[start]?.startsWith("-") === true) {
7948
+ const word = words[start] ?? "";
7949
+ const replace = XARGS_REPLACE.exec(word)?.groups;
7950
+ if (word === "--") {
7951
+ start += 1;
7952
+ break;
7953
+ }
7954
+ if (word === "-I") {
7955
+ token = words[start + 1] ?? null;
7956
+ start += 2;
7957
+ } else if (replace !== void 0) {
7958
+ const short = replace["short"];
7959
+ token = replace["attached"] ?? replace["long"] ?? (short === void 0 || short === "" ? "{}" : short);
7960
+ start += 1;
7961
+ } else if (word.startsWith("-J")) {
7962
+ token = null;
7963
+ start += word === "-J" ? 2 : 1;
7964
+ } else if (XARGS_VALUE_OPTION.test(word)) start += 2;
7965
+ else if (XARGS_FLAG.test(word) || XARGS_ATTACHED_VALUE.test(word)) start += 1;
7966
+ else return null;
7029
7967
  }
7030
- if (group === "issue" && verb !== "" && !GH_READS.has(verb)) return [{
7031
- kind: "remote-write",
7032
- target: `gh issue ${verb}`
7033
- }];
7034
- if (group === "api") return collectGhAPIFindings(args.slice(1), scope);
7035
- if (group === "auth" && [
7036
- "refresh",
7037
- "login",
7038
- "logout"
7039
- ].includes(verb)) return [{
7040
- kind: "credential",
7041
- target: `gh auth ${verb}`
7042
- }];
7043
- if (GH_CREDENTIAL_WRITES.has(group) && !GH_READS.has(verb)) return [{
7044
- kind: "credential",
7045
- target: `gh ${group} ${verb}`
7046
- }];
7047
- if (GH_REMOTE_WRITES.has(group) && !GH_READS.has(verb)) return [{
7048
- kind: "remote-write",
7049
- target: `gh ${group} ${verb}`
7050
- }];
7051
- return [];
7052
- }
7053
- const GH_PR_VALUE_OPTIONS = {
7054
- comment: /* @__PURE__ */ new Set([
7055
- "-b",
7056
- "--body",
7057
- "-F",
7058
- "--body-file",
7059
- "--attach"
7060
- ]),
7061
- merge: /* @__PURE__ */ new Set([
7062
- "-A",
7063
- "--author-email",
7064
- "-b",
7065
- "--body",
7066
- "-F",
7067
- "--body-file",
7068
- "--match-head-commit",
7069
- "-t",
7070
- "--subject"
7071
- ]),
7072
- edit: /* @__PURE__ */ new Set([
7073
- "--add-assignee",
7074
- "--add-label",
7075
- "--add-project",
7076
- "--add-reviewer",
7077
- "--attach",
7078
- "-B",
7079
- "--base",
7080
- "-b",
7081
- "--body",
7082
- "-F",
7083
- "--body-file",
7084
- "-m",
7085
- "--milestone",
7086
- "--remove-assignee",
7087
- "--remove-label",
7088
- "--remove-project",
7089
- "--remove-reviewer",
7090
- "-t",
7091
- "--title"
7092
- ]),
7093
- close: /* @__PURE__ */ new Set(["-c", "--comment"]),
7094
- review: /* @__PURE__ */ new Set([
7095
- "-b",
7096
- "--body",
7097
- "-F",
7098
- "--body-file"
7099
- ]),
7100
- ready: /* @__PURE__ */ new Set(),
7101
- reopen: /* @__PURE__ */ new Set(["-c", "--comment"])
7102
- };
7103
- const GH_REPO_OPTIONS = /* @__PURE__ */ new Set(["-R", "--repo"]);
7104
- function findPullRequestSelector(verb, args) {
7105
- const valueOptions = GH_PR_VALUE_OPTIONS[verb] ?? /* @__PURE__ */ new Set();
7106
- let repo;
7107
- let selector;
7108
- for (let index = 0; index < args.length; index += 1) {
7109
- const word = args[index] ?? "";
7110
- if (GH_REPO_OPTIONS.has(word)) repo = args[index + 1];
7111
- else if (word.startsWith("--repo=")) repo = word.slice(7);
7112
- if (GH_REPO_OPTIONS.has(word) || valueOptions.has(word)) index += 1;
7113
- else if (selector === void 0 && !word.startsWith("-")) selector = word;
7114
- }
7115
- if (selector === void 0) return null;
7116
- const url = /^https?:\/\/(?<repo>[^/]+\/[^/]+\/[^/]+)\/pull\/(?<number>\d+)(?:[/?#]|$)/u.exec(selector)?.groups;
7117
- if (url !== void 0) return {
7118
- number: Number(url["number"]),
7119
- repo: url["repo"]
7120
- };
7968
+ const [name, ...args] = words.slice(start);
7969
+ if (token === null || token === "" || name === void 0 || [name, ...args].some((word) => word.includes(token))) return null;
7121
7970
  return {
7122
- number: /^#?\d+$/u.test(selector) ? Number(selector.replace("#", "")) : null,
7123
- repo
7971
+ name,
7972
+ args
7124
7973
  };
7125
7974
  }
7126
- function isOwnedPullRequest(number, repo, scope) {
7127
- const wanted = repo?.toLowerCase().replace(/\.git$/u, "");
7128
- const slugs = new Set(scope.remotes.map((remote) => toRepositorySlug(remote.url)));
7129
- return scope.pullRequests.some((pull) => pull.number === number && (wanted === void 0 ? slugs.size === 1 && slugs.has(pull.repository) : pull.repository === wanted || pull.repository.endsWith(`/${wanted}`)));
7975
+ const XARGS_VALUE_OPTIONS = /* @__PURE__ */ new Set([
7976
+ "-I",
7977
+ "-J",
7978
+ "-L",
7979
+ "-n",
7980
+ "-P",
7981
+ "-s",
7982
+ "-d",
7983
+ "-E"
7984
+ ]);
7985
+ function isBranchSweep(entries, piped) {
7986
+ const deleter = entries.at(-1)?.words ?? [];
7987
+ if (deleter[0] !== "xargs" || piped.at(-1) !== true) return false;
7988
+ let start = 1;
7989
+ while (deleter[start]?.startsWith("-") === true) {
7990
+ if (/^(?:-a|--arg-file)/u.test(deleter[start] ?? "")) return false;
7991
+ start += XARGS_VALUE_OPTIONS.has(deleter[start] ?? "") ? 2 : 1;
7992
+ }
7993
+ const [program, subcommand, ...rest] = deleter.slice(start);
7994
+ if (program !== "git" || subcommand !== "branch" || !rest.some((word) => /^-(?:d|D|-delete)$/u.test(word))) return false;
7995
+ let first = entries.length - 2;
7996
+ while (first > 0 && piped[first] === true) first -= 1;
7997
+ const [listing, ...filters] = entries.slice(first, -1).map((entry) => entry.words);
7998
+ return listing !== void 0 && isWholeBranchListing(listing) && filters.every((filter) => isExcludingFilter(filter));
7130
7999
  }
7131
- function collectGhAPIFindings(args, scope) {
7132
- const endpoint = args.find((word) => !word.startsWith("-")) ?? "";
7133
- if (endpoint === "graphql") return args.some((word) => /\bmutation\b/u.test(word)) ? [{
7134
- kind: "remote-write",
7135
- target: "gh api graphql mutation"
7136
- }] : [];
7137
- const methodIndex = args.findIndex((word) => word === "-X" || word === "--method");
7138
- const method = methodIndex === -1 ? null : (args[methodIndex + 1] ?? "").toUpperCase();
7139
- const hasFields = args.some((word) => /^(?:-f|-F|--field|--raw-field|--input)$/u.test(word));
7140
- if (method === "GET" || method === null && !hasFields) return [];
7141
- const pull = /^\/?repos\/(?<repo>[^/]+\/[^/]+)\/(?:pulls|issues)\/(?<number>\d+)(?:\/|$)/u.exec(endpoint)?.groups;
7142
- if (pull !== void 0 && isOwnedPullRequest(Number(pull["number"]), pull["repo"] ?? "", scope) && !endpoint.includes("/merge")) return [];
7143
- return [{
7144
- kind: /secret|collaborator|protection|key|permission/u.test(endpoint) ? "credential" : "remote-write",
7145
- target: `gh api ${endpoint}`
7146
- }];
8000
+ function isWholeBranchListing(words) {
8001
+ const [program, subcommand, ...rest] = words;
8002
+ if (program !== "git") return false;
8003
+ if (subcommand === "branch") return rest.every((word) => /^(?:-a|-r|-v|-vv|--all|--remotes|--no-color|--format=.*)$/u.test(word));
8004
+ if (subcommand !== "for-each-ref") return false;
8005
+ return rest.filter((_word, index) => !/^--(?:format|sort)$/u.test(rest[index - 1] ?? "")).every((word) => /^--(?:format|sort)(?:=.*)?$/u.test(word) || /^refs\/heads\/?$/u.test(word));
7147
8006
  }
7148
- const IAM_WRITE = /^(?:add|attach|change|create|deactivate|delete|detach|enable|put|remove|reset|set|tag|untag|update|upload)-/u;
7149
- function findProgramFinding(name, args) {
7150
- const joined = args.join(" ");
7151
- if (name === "op" && /^(?:item|vault|document) (?:edit|create|delete)\b/u.test(joined)) return {
7152
- kind: "credential",
7153
- target: `op ${args[0] ?? ""} ${args[1] ?? ""}`
7154
- };
7155
- if (name === "aws" && args[0] === "iam" && IAM_WRITE.test(args[1] ?? "")) return {
7156
- kind: "credential",
7157
- target: `aws iam ${args[1] ?? ""}`
7158
- };
7159
- if (name === "docker" && /\b(?:volume|system|image|container) prune\b|\bvolume rm\b|\bdown\b.*(?:-v|--volumes)\b/u.test(joined)) return {
7160
- kind: "prune",
7161
- target: `docker ${joined}`
7162
- };
7163
- if (name === "npm" && args[0] === "publish") return {
7164
- kind: "remote-write",
7165
- target: "npm publish"
7166
- };
7167
- if (name === "kubectl" && /\b(?:apply|delete|replace|patch|scale)\b/u.test(joined)) return {
7168
- kind: "remote-write",
7169
- target: "kubectl write"
7170
- };
7171
- if (name === "terraform" && /\b(?:apply|destroy|import)\b/u.test(joined)) return {
7172
- kind: "remote-write",
7173
- target: "terraform write"
7174
- };
7175
- if (name === "psql" && /\b(?:UPDATE|DELETE|DROP|INSERT|TRUNCATE|ALTER)\b/iu.test(joined)) return {
7176
- kind: "remote-write",
7177
- target: "database write"
7178
- };
7179
- if (/^(?:bun|npm|pnpm|yarn)$/u.test(name) && /\bdeploy\b/u.test(joined) && /prod/u.test(joined)) return {
7180
- kind: "remote-write",
7181
- target: "production deploy"
7182
- };
7183
- return null;
8007
+ const NAME_EDITORS = /* @__PURE__ */ new Set([
8008
+ "tr",
8009
+ "cut",
8010
+ "sort"
8011
+ ]);
8012
+ function isExcludingFilter(words) {
8013
+ const [program, ...args] = words;
8014
+ if (program === "grep" || program === "egrep") return args.some((word) => /^-[a-zA-Z]*v[a-zA-Z]*$|^--invert-match$/u.test(word));
8015
+ if (program === "sed") return isSubstitutionOnly(args);
8016
+ if (program === "uniq") return args.every((word) => !/^-[a-zA-Z]*[dDu]/u.test(word));
8017
+ return program !== void 0 && NAME_EDITORS.has(program);
7184
8018
  }
7185
- function collectInPlaceFiles(args) {
7186
- const files = [];
7187
- let skipNext = false;
7188
- let sawScript = false;
7189
- for (const word of args) if (skipNext) {
7190
- skipNext = false;
7191
- sawScript = true;
7192
- } else if (/^-\w*e$/u.test(word)) skipNext = true;
7193
- else if (!word.startsWith("-")) {
7194
- if (sawScript) files.push(word);
7195
- else sawScript = true;
7196
- }
7197
- return files;
8019
+ function isSubstitutionOnly(args) {
8020
+ const scripts = args.filter((word) => word !== "-e" && word !== "-E" && word !== "-r");
8021
+ return scripts.length > 0 && scripts.every((script) => /^s(?<delimiter>[^\w\s;])(?:(?!\k<delimiter>).)*\k<delimiter>(?:(?!\k<delimiter>).)*\k<delimiter>g?$/u.test(script));
7198
8022
  }
7199
- function isInScope(path, scope, scratchPaths) {
7200
- if (scratchPaths.some((entry) => isUnder$1(path, entry)) || scope.pathGlobs.some((glob) => matchesGlob(path, glob))) return true;
7201
- return scope.worktrees.some((worktree) => {
7202
- const isNested = isUnder$1(path, join(worktree, ".worktrees"));
7203
- const isOtherOwned = scope.worktrees.some((other) => other !== worktree && isUnder$1(path, other));
7204
- return isUnder$1(path, worktree) && (!isNested || isOtherOwned);
7205
- });
8023
+ function collectCallFindings(name, args, directory, cwd, scope, scratchPaths) {
8024
+ return [...collectProgramFindings(name, args, {
8025
+ directory,
8026
+ cwd,
8027
+ scope,
8028
+ scratchPaths
8029
+ }), ...collectRecordFindings(name, args)];
7206
8030
  }
7207
- function isUnder$1(path, root) {
7208
- return path === root || path.startsWith(`${root}/`);
8031
+ function collectRecordFindings(name, args) {
8032
+ const words = [name, ...args];
8033
+ const launcher = words.findIndex((word) => word.includes("auto-mode"));
8034
+ return launcher !== -1 && words.slice(launcher + 1).includes("record") ? [{
8035
+ kind: "credential",
8036
+ target: "auto-mode record"
8037
+ }] : [];
7209
8038
  }
7210
8039
  //#endregion
7211
8040
  //#region src/containment/check-containment.ts
@@ -7248,6 +8077,83 @@ function formatList(one, many, values) {
7248
8077
  return `the ${values.length === 1 ? one : many} ${values.join(", ")}`;
7249
8078
  }
7250
8079
  //#endregion
8080
+ //#region src/scope/is-protected-branch.ts
8081
+ function isProtectedBranch(branch, patterns) {
8082
+ return patterns.some((pattern) => buildBranchPattern(pattern).test(branch));
8083
+ }
8084
+ function buildBranchPattern(pattern) {
8085
+ let source = "";
8086
+ let index = 0;
8087
+ while (index < pattern.length) {
8088
+ const char = pattern[index] ?? "";
8089
+ if (pattern.startsWith("**/", index)) {
8090
+ source += "(?:.*/)?";
8091
+ index += 3;
8092
+ } else if (pattern.startsWith("**", index)) {
8093
+ source += ".*";
8094
+ index += 2;
8095
+ } else if (char === "*") {
8096
+ source += "[^/]*";
8097
+ index += 1;
8098
+ } else if (char === "?") {
8099
+ source += "[^/]";
8100
+ index += 1;
8101
+ } else if (char === "[") {
8102
+ const bracket = buildBracketPattern(pattern, index);
8103
+ source += bracket?.source ?? String.raw`\[`;
8104
+ index = bracket?.end ?? index + 1;
8105
+ } else if (char === "\\" && index + 1 < pattern.length) {
8106
+ source += toLiteralPattern(pattern[index + 1] ?? "");
8107
+ index += 2;
8108
+ } else {
8109
+ source += toLiteralPattern(char);
8110
+ index += 1;
8111
+ }
8112
+ }
8113
+ return new RegExp(`^${source}$`, "u");
8114
+ }
8115
+ function buildBracketPattern(pattern, start) {
8116
+ let index = start + 1;
8117
+ const negated = pattern[index] === "!" || pattern[index] === "^";
8118
+ if (negated) index += 1;
8119
+ let members = "";
8120
+ while (index < pattern.length) {
8121
+ if (pattern[index] === "]") return {
8122
+ source: buildClassSource(members, negated),
8123
+ end: index + 1
8124
+ };
8125
+ const low = readClassChar(pattern, index);
8126
+ index = low.end;
8127
+ if (pattern[index] === "-" && index + 1 < pattern.length && pattern[index + 1] !== "]") {
8128
+ const high = readClassChar(pattern, index + 1);
8129
+ index = high.end;
8130
+ members += buildRangeSource(low.char, high.char);
8131
+ } else members += toClassMember(low.char);
8132
+ }
8133
+ return null;
8134
+ }
8135
+ function readClassChar(pattern, index) {
8136
+ const start = pattern[index] === "\\" && index + 1 < pattern.length ? index + 1 : index;
8137
+ const char = String.fromCodePoint(pattern.codePointAt(start) ?? 0);
8138
+ return {
8139
+ char,
8140
+ end: start + char.length
8141
+ };
8142
+ }
8143
+ function buildRangeSource(low, high) {
8144
+ return (low.codePointAt(0) ?? 0) <= (high.codePointAt(0) ?? 0) ? `${toClassMember(low)}-${toClassMember(high)}` : "";
8145
+ }
8146
+ function buildClassSource(members, negated) {
8147
+ if (negated) return `[^/${members}]`;
8148
+ return members === "" ? "(?!)" : `(?!/)[${members}]`;
8149
+ }
8150
+ function toClassMember(char) {
8151
+ return /[\\\][^-]/u.test(char) ? `\\${char}` : char;
8152
+ }
8153
+ function toLiteralPattern(char) {
8154
+ return char.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
8155
+ }
8156
+ //#endregion
7251
8157
  //#region src/scope/protected-branch-names.ts
7252
8158
  const PROTECTED_BRANCH_NAMES = /* @__PURE__ */ new Set([
7253
8159
  "main",
@@ -7279,7 +8185,7 @@ function findNamingWord(finding, words, context) {
7279
8185
  if (grant === void 0) return null;
7280
8186
  if (grant.kind === "pull-request") return findPullRequestWord(grant.number, words);
7281
8187
  if (grant.kind === "path") return findPathWord(finding.target, words, context.home);
7282
- if (PROTECTED_BRANCH_NAMES.has(finding.target) || finding.target === context.defaultBranch) return null;
8188
+ if (PROTECTED_BRANCH_NAMES.has(finding.target) || finding.target === context.defaultBranch || isProtectedBranch(finding.target, context.protectedBranches ?? [])) return null;
7283
8189
  return words.find((word) => word === finding.target) ?? null;
7284
8190
  }
7285
8191
  const PULL_REQUEST_NOUNS = /^(?:pr|pull|request)$/iu;
@@ -7924,10 +8830,19 @@ const VERDICT = /<verdict>\s*(?<verdict>[^<]*?)\s*<\/verdict>/giu;
7924
8830
  const REASON$1 = /<reason>(?<reason>[\s\S]*?)<\/reason>/giu;
7925
8831
  const BASIS = /<basis>\s*(?<basis>[^<]*?)\s*<\/basis>/giu;
7926
8832
  function parseJudgeReply(text) {
7927
- const verdicts = [...text.matchAll(VERDICT)].map((match) => match.groups?.["verdict"]?.toLowerCase());
8833
+ const verdictMatches = [...text.matchAll(VERDICT)];
8834
+ const verdicts = verdictMatches.map((match) => match.groups?.["verdict"]?.toLowerCase());
7928
8835
  const reasons = [...text.matchAll(REASON$1)].map((match) => match.groups?.["reason"]?.trim());
8836
+ const [verdictMatch] = verdictMatches;
7929
8837
  const [verdict] = verdicts;
7930
8838
  const [reason] = reasons;
8839
+ if (verdictMatch !== void 0 && verdicts.length === 1 && verdict === "confirm" && reasons.length === 0) {
8840
+ const unclosedReason = findUnclosedReason(text, verdictMatch.index + verdictMatch[0].length);
8841
+ return unclosedReason === void 0 ? { kind: "unreadable" } : {
8842
+ kind: "confirm",
8843
+ reason: unclosedReason
8844
+ };
8845
+ }
7931
8846
  if (verdicts.length !== 1 || reasons.length !== 1 || reason === void 0 || reason === "" || verdict !== "confirm" && verdict !== "overturn") return { kind: "unreadable" };
7932
8847
  if (verdict === "confirm") return {
7933
8848
  kind: "confirm",
@@ -7942,6 +8857,15 @@ function parseJudgeReply(text) {
7942
8857
  reason
7943
8858
  };
7944
8859
  }
8860
+ const REASON_OPENING = /<reason>/giu;
8861
+ const REASON_CLOSING = /<\/reason>/iu;
8862
+ function findUnclosedReason(text, verdictEnd) {
8863
+ const openings = [...text.matchAll(REASON_OPENING)];
8864
+ const [opening] = openings;
8865
+ if (openings.length !== 1 || opening === void 0 || opening.index < verdictEnd || REASON_CLOSING.test(text)) return;
8866
+ const reason = text.slice(opening.index + opening[0].length).trim();
8867
+ return reason === "" ? void 0 : reason;
8868
+ }
7945
8869
  //#endregion
7946
8870
  //#region src/messages/anthropic-client.ts
7947
8871
  async function sendMessage(provider, apiKey, request, signal) {
@@ -8038,11 +8962,16 @@ async function runClaudeCode(provider, request, env, signal) {
8038
8962
  cwd: tmpdir(),
8039
8963
  env: buildChildEnv(env)
8040
8964
  });
8041
- const body = CLAUDE_OUTPUT.safeParse(parseOutputJSON(result.stdout));
8965
+ const output = parseOutputJSON(result.stdout);
8966
+ const body = CLAUDE_OUTPUT.safeParse(output);
8042
8967
  if (result.exitCode !== 0 || !body.success || body.data.is_error || body.data.result === void 0) throw new Error(`claude -p failed with exit code ${String(result.exitCode)}`);
8968
+ const usage = MODEL_USAGE_OUTPUT.safeParse(output).data?.modelUsage;
8969
+ const stops = SAFETY_STOPS_OUTPUT.safeParse(output).data?.safety_stops;
8043
8970
  return {
8044
8971
  text: body.data.result,
8045
- model: provider.model
8972
+ model: pickAnsweringModel(usage, provider.model),
8973
+ safetyStops: stops ?? null,
8974
+ fallback: isFallbackReply(usage, stops)
8046
8975
  };
8047
8976
  }
8048
8977
  function buildChildEnv(env) {
@@ -8061,6 +8990,8 @@ const CLAUDE_OUTPUT = object({
8061
8990
  is_error: boolean(),
8062
8991
  result: string().optional()
8063
8992
  });
8993
+ const MODEL_USAGE_OUTPUT = object({ modelUsage: record(string(), object({ outputTokens: number() })) });
8994
+ const SAFETY_STOPS_OUTPUT = object({ safety_stops: number() });
8064
8995
  function parseOutputJSON(text) {
8065
8996
  try {
8066
8997
  return JSON.parse(text);
@@ -8068,13 +8999,27 @@ function parseOutputJSON(text) {
8068
8999
  return null;
8069
9000
  }
8070
9001
  }
9002
+ function pickAnsweringModel(usage, requested) {
9003
+ const [top] = Object.entries(usage ?? {}).filter(([, entry]) => entry.outputTokens > 0).toSorted(([, left], [, right]) => right.outputTokens - left.outputTokens);
9004
+ return top === void 0 ? requested : top[0];
9005
+ }
9006
+ function isFallbackReply(usage, stops) {
9007
+ if (stops !== void 0) return stops > 0;
9008
+ const entries = Object.values(usage ?? {});
9009
+ return entries.some((entry) => entry.outputTokens === 0) && entries.some((entry) => entry.outputTokens > 0);
9010
+ }
8071
9011
  //#endregion
8072
9012
  //#region src/judge/send-judge-message.ts
8073
9013
  async function sendJudgeMessage(provider, request, transport, signal) {
8074
- if (provider.protocol === "claude-code") return (await runClaudeCode(provider, request, transport.env, signal)).text;
9014
+ if (provider.protocol === "claude-code") return runClaudeCode(provider, request, transport.env, signal);
8075
9015
  if (provider.protocol === "system-one") throw new Error("Jev returns typed answers and cannot write a judge reason");
8076
9016
  if (transport.apiKey === null) throw new Error("no API key for the judge");
8077
- return (await sendMessage(provider, transport.apiKey, request, signal)).text;
9017
+ return {
9018
+ text: (await sendMessage(provider, transport.apiKey, request, signal)).text,
9019
+ model: provider.model,
9020
+ safetyStops: null,
9021
+ fallback: false
9022
+ };
8078
9023
  }
8079
9024
  //#endregion
8080
9025
  //#region src/judge/classify-with-judge.ts
@@ -8083,6 +9028,7 @@ async function classifyWithJudge(payload, evidence, judge, options) {
8083
9028
  const now = options.now ?? Date.now;
8084
9029
  const overturns = evidence.containment === void 0 ? options.overturns : "consent";
8085
9030
  let failure = null;
9031
+ let answered = null;
8086
9032
  let verdict;
8087
9033
  try {
8088
9034
  failure = "credential";
@@ -8109,7 +9055,7 @@ async function classifyWithJudge(payload, evidence, judge, options) {
8109
9055
  const timer = (options.timeout ?? ((ms) => AbortSignal.timeout(ms)))(toTimerDelay(Math.min(judge.timeoutMs, remainingMs)));
8110
9056
  const signal = options.signal === void 0 ? timer : AbortSignal.any([timer, options.signal]);
8111
9057
  failure = "request";
8112
- const reply = parseJudgeReply(await sendJudgeMessage(judge, {
9058
+ answered = await sendJudgeMessage(judge, {
8113
9059
  system,
8114
9060
  user
8115
9061
  }, {
@@ -8118,7 +9064,8 @@ async function classifyWithJudge(payload, evidence, judge, options) {
8118
9064
  }, signal).catch((error) => {
8119
9065
  if (signal.aborted) failure = "timeout";
8120
9066
  throw error;
8121
- }));
9067
+ });
9068
+ const reply = parseJudgeReply(answered.text);
8122
9069
  failure = reply.kind === "unreadable" ? "unreadable" : null;
8123
9070
  verdict = buildJudgedVerdict(evidence.rule, evidence.basis, reply, overturns, evidence.containment?.reason);
8124
9071
  } catch {
@@ -8131,6 +9078,8 @@ async function classifyWithJudge(payload, evidence, judge, options) {
8131
9078
  failureReason: failure,
8132
9079
  overturnBasis: verdict.overturnBasis,
8133
9080
  model: judge.model,
9081
+ answeringModel: answered?.model ?? null,
9082
+ safetyStops: answered?.safetyStops ?? null,
8134
9083
  rule: evidence.rule.source === "shipped" ? evidence.rule.name : evidence.rule.source,
8135
9084
  tier: evidence.rule.tier,
8136
9085
  elapsedMs: Math.round(performance.now() - start)
@@ -8268,6 +9217,21 @@ function buildTaskContext(context) {
8268
9217
  return result;
8269
9218
  }
8270
9219
  //#endregion
9220
+ //#region src/shell/split-git-command.ts
9221
+ function splitGitCommand(args, directory, home) {
9222
+ let gitDirectory = directory;
9223
+ let index = 0;
9224
+ while (index < args.length && args[index]?.startsWith("-") === true) {
9225
+ const option = args[index];
9226
+ if (option === "-C") gitDirectory = resolveShellPath(gitDirectory, args[index + 1] ?? "", home);
9227
+ index += option === "-C" || option === "-c" ? 2 : 1;
9228
+ }
9229
+ return {
9230
+ directory: gitDirectory,
9231
+ args: args.slice(index)
9232
+ };
9233
+ }
9234
+ //#endregion
8271
9235
  //#region src/model/collect-hook-bypasses.ts
8272
9236
  function collectHookBypasses(command) {
8273
9237
  return splitShellCommand(command).segments.flatMap((segment) => collectSegmentBypasses(segment.text));
@@ -8308,6 +9272,7 @@ const COMMIT_VALUED_LONG = /* @__PURE__ */ new Set([
8308
9272
  "file",
8309
9273
  "fixup",
8310
9274
  "message",
9275
+ "pathspec-from-file",
8311
9276
  "reedit-message",
8312
9277
  "reuse-message",
8313
9278
  "squash",
@@ -8323,7 +9288,7 @@ function findNoVerifyOption(args, subcommand) {
8323
9288
  const [option = "", ...value] = arg.slice(2).split("=");
8324
9289
  if (option.startsWith("no-ve") && "no-verify".startsWith(option)) found = arg;
8325
9290
  else if (option.startsWith("veri") && "verify".startsWith(option)) found = null;
8326
- if (subcommand === "commit" && value.length === 0 && COMMIT_VALUED_LONG.has(option)) index += 1;
9291
+ if (subcommand === "commit" && value.length === 0 && COMMIT_VALUED_LONG.has(findCommitOption(option) ?? "")) index += 1;
8327
9292
  } else if (subcommand === "commit" && arg.startsWith("-") && arg !== "-") {
8328
9293
  const cluster = parseCommitCluster(arg);
8329
9294
  if (cluster.hasNoVerify) found = arg;
@@ -8333,6 +9298,42 @@ function findNoVerifyOption(args, subcommand) {
8333
9298
  }
8334
9299
  return found;
8335
9300
  }
9301
+ const COMMIT_FLAG_LONG = /* @__PURE__ */ new Set([
9302
+ "ahead-behind",
9303
+ "all",
9304
+ "allow-empty",
9305
+ "allow-empty-message",
9306
+ "amend",
9307
+ "branch",
9308
+ "dry-run",
9309
+ "edit",
9310
+ "gpg-sign",
9311
+ "include",
9312
+ "interactive",
9313
+ "long",
9314
+ "no-post-rewrite",
9315
+ "no-verify",
9316
+ "null",
9317
+ "only",
9318
+ "patch",
9319
+ "pathspec-file-nul",
9320
+ "porcelain",
9321
+ "post-rewrite",
9322
+ "quiet",
9323
+ "reset-author",
9324
+ "short",
9325
+ "signoff",
9326
+ "status",
9327
+ "untracked-files",
9328
+ "verbose",
9329
+ "verify"
9330
+ ]);
9331
+ function findCommitOption(option) {
9332
+ const names = [...COMMIT_VALUED_LONG, ...COMMIT_FLAG_LONG];
9333
+ if (names.includes(option)) return option;
9334
+ const matches = names.filter((name) => name.startsWith(option));
9335
+ return matches.length === 1 ? matches[0] ?? null : null;
9336
+ }
8336
9337
  function parseCommitCluster(arg) {
8337
9338
  let hasNoVerify = false;
8338
9339
  for (let index = 1; index < arg.length; index += 1) {
@@ -9211,7 +10212,7 @@ function mergeScopeFacts(all) {
9211
10212
  //#region src/scope/build-task-scope.ts
9212
10213
  function buildTaskScope(input) {
9213
10214
  const merged = mergeScopeFacts(input.facts);
9214
- const branches = merged.branches.filter((branch) => branch !== input.defaultBranch && !(input.defaultBranch === null && PROTECTED_BRANCH_NAMES.has(branch)));
10215
+ const branches = merged.branches.filter((branch) => branch !== input.defaultBranch && !(input.defaultBranch === null && PROTECTED_BRANCH_NAMES.has(branch)) && !isProtectedBranch(branch, input.protectedBranches ?? []));
9215
10216
  const repositories = new Set(input.remotes.map((remote) => toRepositorySlug(remote.url)));
9216
10217
  const pullRequests = merged.pullRequests.filter((pull) => branches.includes(pull.head) && repositories.has(pull.repository)).map((pull) => ({
9217
10218
  number: pull.number,
@@ -9428,6 +10429,7 @@ async function loadTaskScope(request, sources, host, stderr) {
9428
10429
  home: host.home,
9429
10430
  currentBranch: branch,
9430
10431
  defaultBranch: facts.head?.defaultBranch ?? null,
10432
+ protectedBranches: request.protectedBranches,
9431
10433
  remotes: facts.remotes,
9432
10434
  facts: sourceFacts
9433
10435
  });
@@ -9616,7 +10618,8 @@ async function classifyUserNamedGrant(request, config, options, facts) {
9616
10618
  if (judge === null || message === null || options.localOnly === true) return null;
9617
10619
  const targets = findUserNamedGrant(containment.findings, message, {
9618
10620
  home: facts.scope.home,
9619
- defaultBranch: facts.checkout?.head?.defaultBranch ?? null
10621
+ defaultBranch: facts.checkout?.head?.defaultBranch ?? null,
10622
+ protectedBranches: config.protectedBranches
9620
10623
  });
9621
10624
  if (targets === null) return null;
9622
10625
  const text = await readContainmentRule().catch(() => null);
@@ -9645,7 +10648,8 @@ async function tryLoadTaskScope(request, config, host, stderr) {
9645
10648
  return await loadTaskScope({
9646
10649
  ...request,
9647
10650
  checkout: request.checkout ?? void 0,
9648
- stateDir: resolveXDGDir(host, "state")
10651
+ stateDir: resolveXDGDir(host, "state"),
10652
+ protectedBranches: config.protectedBranches
9649
10653
  }, config.scopeSources ?? DEFAULT_SCOPE_SOURCES, host, stderr);
9650
10654
  } catch {
9651
10655
  return null;
@@ -9694,268 +10698,6 @@ async function tryUpdateDenialBudget(stderr, path, plan) {
9694
10698
  }
9695
10699
  }
9696
10700
  //#endregion
9697
- //#region src/scope/read-pull-request.ts
9698
- const LOOKUP_TIMEOUT_MS = 5e3;
9699
- const MAX_LOOKUP_OUTPUT_BYTES = 65536;
9700
- async function readPullRequest(repository, number, options = {}) {
9701
- const [file, ...prefix] = options.command ?? ["gh"];
9702
- const args = [
9703
- "pr",
9704
- "view",
9705
- String(number),
9706
- "--repo",
9707
- repository,
9708
- "--json",
9709
- "headRefName,createdAt"
9710
- ];
9711
- const result = await runCapped(file, [
9712
- ...prefix,
9713
- ...args,
9714
- "--jq",
9715
- "[.headRefName, .createdAt] | @tsv"
9716
- ], {
9717
- signal: AbortSignal.timeout(options.timeoutMs ?? LOOKUP_TIMEOUT_MS),
9718
- maxOutputBytes: MAX_LOOKUP_OUTPUT_BYTES
9719
- });
9720
- const [head = "", created = ""] = result.stdout.trim().split(" ");
9721
- const createdAt = Date.parse(created);
9722
- return result.exitCode === 0 && head !== "" && !Number.isNaN(createdAt) ? {
9723
- head,
9724
- createdAt
9725
- } : null;
9726
- }
9727
- //#endregion
9728
- //#region src/git/read-branch-created-at.ts
9729
- async function readBranchCreatedAt(commonDir, branch) {
9730
- if (!/^[\w./-]+$/u.test(branch) || branch.split("/").includes("..")) return null;
9731
- const [first = ""] = (await readFile(join(commonDir, "logs", "refs", "heads", branch), "utf8").catch(() => "")).split("\n");
9732
- const seconds = /^0+ [\da-f]+ .* (?<time>\d+) [+-]\d{4}\t/u.exec(first)?.groups?.["time"];
9733
- return seconds === void 0 ? null : Number(seconds) * 1e3;
9734
- }
9735
- //#endregion
9736
- //#region src/scope/collect-pull-request-addresses.ts
9737
- function collectPullRequestAddresses(text) {
9738
- const addresses = /* @__PURE__ */ new Map();
9739
- for (const match of text.matchAll(PULL_REQUEST_URL)) {
9740
- const repository = toRepositorySlug(`https://${match.groups?.["repository"] ?? ""}`);
9741
- const number = Number(match.groups?.["number"]);
9742
- if (repository !== null) addresses.set(`${repository}#${String(number)}`, {
9743
- repository,
9744
- number
9745
- });
9746
- }
9747
- return [...addresses.values()];
9748
- }
9749
- const PULL_REQUEST_URL = /https:\/\/(?<repository>[^/\s]+\/[^/\s]+\/[^/\s]+)\/pull\/(?<number>[1-9]\d*)\b/gu;
9750
- //#endregion
9751
- //#region mods/auto-mode/contract/scope-commands.ts
9752
- const SCOPE_COMMANDS = {
9753
- git: [
9754
- "worktree add",
9755
- "checkout",
9756
- "switch",
9757
- "branch"
9758
- ],
9759
- gh: ["pr create"]
9760
- };
9761
- //#endregion
9762
- //#region src/scope/collect-scope-events.ts
9763
- function collectScopeEvents(command, cwd, home) {
9764
- return collectShellCommands(command, cwd, home).flatMap((entry) => {
9765
- const [name, ...args] = entry.words;
9766
- const directory = entry.directory;
9767
- if (directory === null) return [];
9768
- if (name === "git") return collectGitEvents(args, directory, home);
9769
- return name === "gh" && findScopeCommand(SCOPE_COMMANDS.gh, args) !== null ? [{
9770
- kind: "pull-request",
9771
- directory,
9772
- head: findOptionValue(args, ["--head", "-H"])
9773
- }] : [];
9774
- });
9775
- }
9776
- function collectGitEvents(args, cwd, home) {
9777
- const git = splitGitCommand(args, cwd, home);
9778
- const found = findScopeCommand(SCOPE_COMMANDS.git, git.args);
9779
- if (git.directory === null || found === null) return [];
9780
- return GIT_EVENT_COLLECTORS[found.command](found.rest, git.directory, home);
9781
- }
9782
- function findScopeCommand(commands, args) {
9783
- for (const command of commands) {
9784
- const words = command.split(" ");
9785
- if (words.every((word, index) => args[index] === word)) return {
9786
- command,
9787
- rest: args.slice(words.length)
9788
- };
9789
- }
9790
- return null;
9791
- }
9792
- const GIT_EVENT_COLLECTORS = {
9793
- "worktree add": collectWorktreeEvents,
9794
- checkout: collectSwitchEvents,
9795
- switch: collectSwitchEvents,
9796
- branch: collectBranchEvents
9797
- };
9798
- function collectWorktreeEvents(rest, directory, home) {
9799
- const path = findWorktreePath(rest);
9800
- const resolved = path === null ? null : resolveShellPath(directory, path, home);
9801
- return resolved === null ? [] : [{
9802
- kind: "worktree",
9803
- path: resolved
9804
- }];
9805
- }
9806
- const WORKTREE_VALUE_OPTIONS = /* @__PURE__ */ new Set([
9807
- "-b",
9808
- "-B",
9809
- "--reason"
9810
- ]);
9811
- function findWorktreePath(args) {
9812
- for (let index = 0; index < args.length; index += 1) {
9813
- const word = args[index] ?? "";
9814
- if (WORKTREE_VALUE_OPTIONS.has(word)) index += 1;
9815
- else if (!word.startsWith("-")) return word;
9816
- }
9817
- return null;
9818
- }
9819
- const BRANCH_CREATE_OPTIONS = /* @__PURE__ */ new Set([
9820
- "-b",
9821
- "-B",
9822
- "-c",
9823
- "-C",
9824
- "--create",
9825
- "--force-create"
9826
- ]);
9827
- const BRANCH_OTHER_OPTIONS = /^-(?:d|D|m|M|c|C|l|a|r|v|vv|u)$|^--(?:delete|move|copy|list|all|remotes|verbose|show-current|contains|no-contains|merged|no-merged|points-at|format|sort|set-upstream-to|unset-upstream|edit-description)/u;
9828
- function collectSwitchEvents(rest, directory) {
9829
- const name = findOptionValue(rest, [...BRANCH_CREATE_OPTIONS]);
9830
- return name === null ? [] : [{
9831
- kind: "branch",
9832
- name,
9833
- directory
9834
- }];
9835
- }
9836
- function collectBranchEvents(rest, directory) {
9837
- if (rest.some((word) => BRANCH_OTHER_OPTIONS.test(word))) return [];
9838
- const name = rest.find((word) => !word.startsWith("-"));
9839
- return name === void 0 || isUnresolvedWord(name) ? [] : [{
9840
- kind: "branch",
9841
- name,
9842
- directory
9843
- }];
9844
- }
9845
- function findOptionValue(args, options) {
9846
- const index = args.findIndex((word) => options.includes(word));
9847
- const value = index === -1 ? void 0 : args[index + 1];
9848
- return value === void 0 || isUnresolvedWord(value) ? null : value;
9849
- }
9850
- //#endregion
9851
- //#region src/scope/merge-session-scope.ts
9852
- function mergeSessionScope(all) {
9853
- const branches = /* @__PURE__ */ new Map();
9854
- const pullRequests = /* @__PURE__ */ new Map();
9855
- for (const branch of all.flatMap((scope) => scope.branches)) branches.set(`${branch.commonDir}\0${branch.name}`, branch);
9856
- for (const pull of all.flatMap((scope) => scope.pullRequests)) pullRequests.set(`${pull.repository}#${String(pull.number)}`, pull);
9857
- return {
9858
- worktrees: [...new Set(all.flatMap((scope) => scope.worktrees))],
9859
- branches: [...branches.values()],
9860
- pullRequests: [...pullRequests.values()]
9861
- };
9862
- }
9863
- //#endregion
9864
- //#region src/scope/write-session-scope.ts
9865
- async function writeSessionScope(path, added, clock) {
9866
- await updateJSONFile(path, loadSessionScope, (current) => mergeSessionScope([current, added]), clock);
9867
- }
9868
- //#endregion
9869
- //#region src/scope/update-session-scope.ts
9870
- const MAX_CALL_AGE_MS = 9e5;
9871
- async function updateSessionScope(request, options) {
9872
- const age = options.now - request.startedAt;
9873
- if (age < -6e4 || age > MAX_CALL_AGE_MS) return EMPTY_SESSION_SCOPE;
9874
- const events = collectScopeEvents(request.command, request.cwd, options.home);
9875
- const scope = mergeSessionScope(await Promise.all(events.map((event) => tryVerifyScopeEvent(event, request, options))));
9876
- if (scope.worktrees.length + scope.branches.length + scope.pullRequests.length > 0) await writeSessionScope(resolveSessionScopePath(options.stateDir, request.sessionID), scope, options.lockClock);
9877
- return scope;
9878
- }
9879
- async function tryVerifyScopeEvent(event, request, options) {
9880
- try {
9881
- return await (options.verifyEvent === void 0 ? verifyScopeEvent(event, request, options) : options.verifyEvent(event, request));
9882
- } catch {
9883
- return EMPTY_SESSION_SCOPE;
9884
- }
9885
- }
9886
- const FORGE_CLOCK_SKEW_MS = 6e4;
9887
- const FILE_CLOCK_SLACK_MS = 50;
9888
- function verifyScopeEvent(event, request, options) {
9889
- return M(event).returnType().with({ kind: "worktree" }, (worktree) => verifyWorktreeEvent(worktree.path, request, options)).with({ kind: "branch" }, (branch) => verifyBranchEvent(branch, request, options)).with({ kind: "pull-request" }, (pull) => verifyPullRequestEvent(pull.directory, request, options)).exhaustive();
9890
- }
9891
- async function verifyWorktreeEvent(path, request, options) {
9892
- const link = await stat(join(path, ".git"));
9893
- const facts = await loadCheckoutFacts(path, options.env);
9894
- const checkout = facts.checkout;
9895
- if (!link.isFile() || link.mtimeMs < request.startedAt - FILE_CLOCK_SLACK_MS || checkout?.worktree !== path) return EMPTY_SESSION_SCOPE;
9896
- const branch = facts.head?.branch ?? null;
9897
- if (branch === null) return {
9898
- ...EMPTY_SESSION_SCOPE,
9899
- worktrees: [path]
9900
- };
9901
- const isNew = await isBranchCreatedSince(checkout.commonDir, branch, request.startedAt);
9902
- return {
9903
- ...EMPTY_SESSION_SCOPE,
9904
- worktrees: [path],
9905
- branches: isNew ? [{
9906
- name: branch,
9907
- commonDir: checkout.commonDir
9908
- }] : []
9909
- };
9910
- }
9911
- async function verifyBranchEvent(event, request, options) {
9912
- const checkout = await findCheckout(event.directory, options.env);
9913
- if (checkout === null) return EMPTY_SESSION_SCOPE;
9914
- return await isBranchCreatedSince(checkout.commonDir, event.name, request.startedAt) ? {
9915
- ...EMPTY_SESSION_SCOPE,
9916
- branches: [{
9917
- name: event.name,
9918
- commonDir: checkout.commonDir
9919
- }]
9920
- } : EMPTY_SESSION_SCOPE;
9921
- }
9922
- async function verifyPullRequestEvent(directory, request, options) {
9923
- const facts = await loadCheckoutFacts(directory, options.env);
9924
- if (facts.checkout === null) return EMPTY_SESSION_SCOPE;
9925
- const repositories = new Set(facts.remotes.map((remote) => toRepositorySlug(remote.url)));
9926
- const addresses = collectPullRequestAddresses(request.resultText).filter((address) => repositories.has(address.repository));
9927
- const pulls = await Promise.all(addresses.map(async (address) => {
9928
- const pull = await options.readPullRequest(address.repository, address.number);
9929
- return pull !== null && pull.createdAt >= request.startedAt - FORGE_CLOCK_SKEW_MS ? [{
9930
- ...address,
9931
- head: pull.head
9932
- }] : [];
9933
- }));
9934
- return {
9935
- ...EMPTY_SESSION_SCOPE,
9936
- pullRequests: pulls.flat()
9937
- };
9938
- }
9939
- async function isBranchCreatedSince(commonDir, branch, since) {
9940
- const createdAt = await readBranchCreatedAt(commonDir, branch);
9941
- if (createdAt === null || createdAt < Math.floor(since / 1e3) * 1e3) return false;
9942
- const ref = await stat(join(commonDir, "refs", "heads", branch)).catch(() => null);
9943
- return ref !== null && ref.mtimeMs >= since - FILE_CLOCK_SLACK_MS;
9944
- }
9945
- //#endregion
9946
- //#region src/record-scope.ts
9947
- async function recordScope(request, config, options = {}) {
9948
- const host = options.host ?? readHostEnvironment();
9949
- if (!Object.values(config.scopeSources ?? DEFAULT_SCOPE_SOURCES).some((source) => source.kind === "session")) return EMPTY_SESSION_SCOPE;
9950
- return await updateSessionScope(request, {
9951
- now: options.now ?? Date.now(),
9952
- stateDir: resolveXDGDir(host, "state"),
9953
- home: host.home,
9954
- env: host.env,
9955
- readPullRequest
9956
- });
9957
- }
9958
- //#endregion
9959
10701
  //#region src/request/action-request-schema.ts
9960
10702
  const userTaskSchema = strictObject({
9961
10703
  text: string(),
@@ -10011,28 +10753,4 @@ function parseActionRequest(body) {
10011
10753
  };
10012
10754
  }
10013
10755
  //#endregion
10014
- //#region src/request/scope-record-request-schema.ts
10015
- const scopeRecordRequestSchema = strictObject({
10016
- sessionID: string().min(1),
10017
- cwd: string().min(1),
10018
- startedAt: number().int().positive(),
10019
- command: string().min(1),
10020
- resultText: string()
10021
- });
10022
- //#endregion
10023
- //#region src/request/parse-scope-record-request.ts
10024
- function parseScopeRecordRequest(body) {
10025
- const parsed = scopeRecordRequestSchema.safeParse(body);
10026
- return parsed.success ? parsed.data : null;
10027
- }
10028
- //#endregion
10029
- //#region src/request/render-verdict.ts
10030
- function renderVerdict(verdict) {
10031
- const rendered = M(verdict).returnType().with({ kind: "allow" }, () => ({ decision: "allow" })).with({ kind: "deny" }, (denied) => ({
10032
- decision: "deny",
10033
- reason: `[${denied.rule}] ${denied.reason}`
10034
- })).exhaustive();
10035
- return JSON.stringify(rendered);
10036
- }
10037
- //#endregion
10038
- export { readHostEnvironment as C, resolveConfigPath as S, resolveXDGDir as T, tryClassifyEdit as _, classifyAction as a, loadConfig as b, classifyWithModel as c, DecisionRequestError as d, buildDecisionRequest as f, loadClaudeSettings as g, checkContainment as h, recordScope as i, parseModelVerdict as l, writeActionDiagnostic as m, parseScopeRecordRequest as n, buildTaskScope as o, loadPolicy as p, parseActionRequest as r, classifyLocally as s, renderVerdict as t, sendDecision as u, findGitEntry as v, PRESETS as w, resolveApiKey as x, DEFAULT_CONFIG as y };
10756
+ export { resolveXDGDir as C, string as D, strictObject as E, CLI_LIMITS as S, number as T, findGitEntry as _, EMPTY_SESSION_SCOPE as a, readHostEnvironment as b, loadPolicy as c, writeActionDiagnostic as d, toRepositorySlug as f, findCheckout as g, isUnresolvedWord as h, loadSessionScope as i, resolveShippedPolicyDir as l, resolveShellPath as m, classifyAction as n, splitGitCommand as o, collectShellCommands as p, resolveSessionScopePath as r, M as s, parseActionRequest as t, loadCheckoutFacts as u, DEFAULT_SCOPE_SOURCES as v, updateJSONFile as w, runCapped as x, loadConfig as y };