auto-mode 0.10.0 → 0.11.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,7 +1,8 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join, normalize, parse, relative, resolve } from "node:path";
3
+ import { dirname, isAbsolute, join, matchesGlob, normalize, parse, relative, resolve } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
+ import { createHash } from "node:crypto";
5
6
  import { existsSync } from "node:fs";
6
7
  //#region node_modules/.bun/zod@4.6.2/node_modules/zod/v4/core/util.js
7
8
  function getEnumValues(entries) {
@@ -2922,7 +2923,7 @@ function bucketFor(state, inst) {
2922
2923
  return bucket;
2923
2924
  }
2924
2925
  let handoff;
2925
- const open = [];
2926
+ const open$1 = [];
2926
2927
  const memo = {
2927
2928
  alloc(_inst, payload, empty) {
2928
2929
  const bucket = handoff;
@@ -2933,7 +2934,7 @@ const memo = {
2933
2934
  issues: null
2934
2935
  };
2935
2936
  bucket.set(payload.value, entry);
2936
- open.push(entry);
2937
+ open$1.push(entry);
2937
2938
  return empty;
2938
2939
  },
2939
2940
  guard(inst) {
@@ -2999,10 +3000,10 @@ const memo = {
2999
3000
  return payload;
3000
3001
  }
3001
3002
  handoff = bucket;
3002
- const depth = open.length;
3003
+ const depth = open$1.length;
3003
3004
  const result = base(payload, ctx);
3004
3005
  handoff = void 0;
3005
- const entry = open.length > depth ? open.pop() : void 0;
3006
+ const entry = open$1.length > depth ? open$1.pop() : void 0;
3006
3007
  if (result instanceof Promise) return result.then((r) => {
3007
3008
  if (entry) entry.issues = r.issues.length ? cloneIssues(r.issues) : NO_ISSUES;
3008
3009
  return r;
@@ -5526,574 +5527,613 @@ function refine(fn, _params = {}) {
5526
5527
  function superRefine(fn, params) {
5527
5528
  return /* @__PURE__ */ _superRefine(fn, params);
5528
5529
  }
5530
+ const PRESETS = {
5531
+ jev: {
5532
+ protocol: "system-one",
5533
+ baseURL: "https://api.typesafe.ai",
5534
+ model: "jev-1.13.0",
5535
+ apiKeyEnv: "TYPESAFE_API_KEY",
5536
+ reasoning: false,
5537
+ maxTokens: 3e3,
5538
+ timeoutMs: 5e3
5539
+ },
5540
+ spark: {
5541
+ baseURL: "https://api.meta.ai",
5542
+ model: "muse-spark-1.3-contributor",
5543
+ apiKeyEnv: "META_API_KEY",
5544
+ reasoning: true,
5545
+ maxTokens: 3e3,
5546
+ timeoutMs: 45e3
5547
+ },
5548
+ claude: {
5549
+ baseURL: "https://api.anthropic.com",
5550
+ model: "claude-haiku-4-5-20251001",
5551
+ apiKeyEnv: "ANTHROPIC_API_KEY",
5552
+ reasoning: true,
5553
+ maxTokens: 3e3,
5554
+ timeoutMs: 45e3
5555
+ },
5556
+ glm: {
5557
+ baseURL: "https://api.z.ai/api/anthropic",
5558
+ model: "glm-5.3-flash",
5559
+ apiKeyEnv: "ZAI_API_KEY",
5560
+ reasoning: true,
5561
+ maxTokens: 3e3,
5562
+ timeoutMs: 6e4
5563
+ }
5564
+ };
5565
+ const MESSAGES_DEFAULTS = {
5566
+ protocol: "messages",
5567
+ baseURL: "https://api.anthropic.com",
5568
+ reasoning: true,
5569
+ maxTokens: 3e3,
5570
+ timeoutMs: 45e3
5571
+ };
5572
+ function findPreset(id) {
5573
+ return Object.hasOwn(PRESETS, id) ? PRESETS[id] : void 0;
5574
+ }
5529
5575
  //#endregion
5530
- //#region src/config/load-claude-rules.ts
5531
- async function loadClaudeRules(path) {
5532
- const empty = {
5533
- environment: [],
5534
- allow: [],
5535
- soft_deny: [],
5536
- hard_deny: []
5537
- };
5538
- if (path === null) return empty;
5539
- const configDir = process.env["CLAUDE_CONFIG_DIR"];
5540
- const base = configDir === void 0 || configDir === "" ? join(homedir(), ".claude") : configDir;
5541
- const settingsPath = path ?? join(base, "settings.json");
5576
+ //#region src/config/read-api-key-from-command.ts
5577
+ function readApiKeyFromCommand(command, options = {}) {
5578
+ const remainingMs = options.deadlineAt === void 0 ? 5e3 : options.deadlineAt - Date.now();
5579
+ if (remainingMs <= 0 || options.signal?.aborted === true) return Promise.resolve(null);
5580
+ return new Promise((resolve) => {
5581
+ const grouped = process.platform !== "win32";
5582
+ const child = spawn("/bin/sh", ["-c", command], {
5583
+ detached: grouped,
5584
+ stdio: [
5585
+ "ignore",
5586
+ "pipe",
5587
+ "ignore"
5588
+ ]
5589
+ });
5590
+ let output = "";
5591
+ let settled = false;
5592
+ let stopped = false;
5593
+ const setResult = (value) => {
5594
+ if (!settled) {
5595
+ settled = true;
5596
+ clearTimeout(timer);
5597
+ options.signal?.removeEventListener("abort", stopChild);
5598
+ resolve(value);
5599
+ }
5600
+ };
5601
+ const stopChild = () => {
5602
+ if (stopped) return;
5603
+ stopped = true;
5604
+ try {
5605
+ if (grouped && child.pid !== void 0) process.kill(-child.pid, "SIGKILL");
5606
+ else child.kill("SIGKILL");
5607
+ } catch {
5608
+ child.kill("SIGKILL");
5609
+ }
5610
+ };
5611
+ const timer = setTimeout(stopChild, Math.min(5e3, remainingMs));
5612
+ options.signal?.addEventListener("abort", stopChild, { once: true });
5613
+ child.stdout.setEncoding("utf8");
5614
+ child.stdout.on("data", (chunk) => {
5615
+ if (stopped) return;
5616
+ output += chunk;
5617
+ if (output.length > 65536) stopChild();
5618
+ });
5619
+ child.on("error", () => {
5620
+ setResult(null);
5621
+ });
5622
+ child.on("close", (code) => {
5623
+ const key = !stopped && code === 0 && output.trim() !== "" ? output.trim() : null;
5624
+ setResult(key);
5625
+ });
5626
+ });
5627
+ }
5628
+ //#endregion
5629
+ //#region src/config/config.ts
5630
+ const SCOPE_SOURCE_KINDS = [
5631
+ "cwd",
5632
+ "session",
5633
+ "globs",
5634
+ "atc"
5635
+ ];
5636
+ const DEFAULT_PROVIDER = findPreset("jev");
5637
+ if (DEFAULT_PROVIDER === void 0) throw new Error("the jev preset is missing");
5638
+ const DEFAULT_DENIAL_BUDGET = {
5639
+ consecutive: 3,
5640
+ perSession: 20
5641
+ };
5642
+ const DEFAULT_SCOPE_SOURCES = {
5643
+ cwd: { kind: "cwd" },
5644
+ session: { kind: "session" }
5645
+ };
5646
+ const DEFAULT_CONFIG = {
5647
+ provider: DEFAULT_PROVIDER,
5648
+ onFailure: "defer",
5649
+ minConfidence: .8,
5650
+ denialBudget: DEFAULT_DENIAL_BUDGET,
5651
+ scopeSources: DEFAULT_SCOPE_SOURCES
5652
+ };
5653
+ function resolveConfigPath() {
5654
+ const xdg = process.env["XDG_CONFIG_HOME"];
5655
+ const base = xdg !== void 0 && xdg !== "" ? xdg : join(homedir(), ".config");
5656
+ return join(base, "auto-mode", "config.json");
5657
+ }
5658
+ async function loadConfig(path = resolveConfigPath()) {
5542
5659
  let raw;
5543
5660
  try {
5544
- raw = await readFile(settingsPath, "utf8");
5661
+ raw = await readFile(path, "utf8");
5545
5662
  } catch (error) {
5546
- if (error instanceof Error && "code" in error && error.code === "ENOENT") return empty;
5547
- throw new Error("Claude settings unreadable", { cause: error });
5663
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return DEFAULT_CONFIG;
5664
+ throw new Error("auto-mode configuration unreadable", { cause: error });
5548
5665
  }
5549
- const entries = array(string()).optional();
5550
- const schema = object({ autoMode: object({
5551
- environment: entries,
5552
- allow: entries,
5553
- soft_deny: entries,
5554
- hard_deny: entries
5555
- }).optional() });
5556
- let body;
5666
+ let json;
5557
5667
  try {
5558
- body = JSON.parse(raw);
5668
+ json = JSON.parse(raw);
5559
5669
  } catch {
5560
- throw new Error("Claude settings contain invalid JSON");
5670
+ throw new Error(`${path} is not valid JSON`);
5561
5671
  }
5562
- const parsed = schema.safeParse(body);
5563
- if (!parsed.success) throw new Error("Claude autoMode settings are invalid");
5564
- const rules = parsed.data;
5672
+ return buildConfig(json, path);
5673
+ }
5674
+ const text = string().min(1);
5675
+ const positive = number().positive();
5676
+ const denialBudgetSchema = strictObject({
5677
+ consecutive: number().int().positive().optional(),
5678
+ perSession: number().int().positive().optional()
5679
+ });
5680
+ const configFileSchema = strictObject({
5681
+ classifiers: record(text, unknown()).optional(),
5682
+ scopeSources: record(text, unknown()).optional(),
5683
+ decision: strictObject({
5684
+ classifier: text.optional(),
5685
+ judge: text.nullable().optional(),
5686
+ minConfidence: number().min(.5).max(1).optional(),
5687
+ onFailure: _enum(["defer", "deny"]).optional(),
5688
+ denialBudget: denialBudgetSchema.optional()
5689
+ }).optional(),
5690
+ policy: strictObject({
5691
+ rulesPath: text.nullable().optional(),
5692
+ frameworkPath: text.nullable().optional(),
5693
+ claudeSettingsPath: text.nullable().optional()
5694
+ }).optional()
5695
+ });
5696
+ function buildConfig(json, path) {
5697
+ const parsed = configFileSchema.safeParse(json);
5698
+ if (!parsed.success) throw new Error(`${path} is not a valid config: ${prettifyError(parsed.error)}`);
5699
+ const file = parsed.data;
5700
+ const warnings = [];
5701
+ const classifiers = buildRegistry(file.classifiers ?? {}, (id, entry) => parseClassifierEntry(id, entry));
5702
+ const scopeSources = buildRegistry(file.scopeSources ?? {}, (id, entry) => parseScopeSourceEntry(id, entry));
5703
+ const configuredSources = file.scopeSources === void 0 ? null : scopeSources.entries;
5704
+ warnings.push(...classifiers.dropped.map((line) => `${path}: classifiers.${line}`), ...scopeSources.dropped.map((line) => `${path}: scopeSources.${line}`), ...collectScopeWarnings(configuredSources).map((line) => `${path}: scopeSources ${line}`));
5705
+ const decision = file.decision ?? {};
5706
+ const policy = file.policy ?? {};
5707
+ const judgeID = decision.judge ?? null;
5565
5708
  return {
5566
- environment: (rules.autoMode?.environment ?? []).filter((entry) => entry !== "$defaults"),
5567
- allow: (rules.autoMode?.allow ?? []).filter((entry) => entry !== "$defaults"),
5568
- soft_deny: (rules.autoMode?.soft_deny ?? []).filter((entry) => entry !== "$defaults"),
5569
- hard_deny: (rules.autoMode?.hard_deny ?? []).filter((entry) => entry !== "$defaults")
5709
+ provider: resolveRole("classifier", decision.classifier ?? "jev", classifiers.entries, classifiers.droppedIDs, path),
5710
+ judge: judgeID === null ? null : resolveRole("judge", judgeID, classifiers.entries, classifiers.droppedIDs, path),
5711
+ scopeSources: configuredSources ?? DEFAULT_SCOPE_SOURCES,
5712
+ classifierPath: expandHomePath(policy.frameworkPath ?? void 0),
5713
+ rulesPath: expandHomePath(policy.rulesPath ?? void 0),
5714
+ onFailure: decision.onFailure ?? DEFAULT_CONFIG.onFailure,
5715
+ claudeSettingsPath: policy.claudeSettingsPath === null ? null : expandHomePath(policy.claudeSettingsPath),
5716
+ minConfidence: decision.minConfidence ?? DEFAULT_CONFIG.minConfidence,
5717
+ denialBudget: {
5718
+ consecutive: decision.denialBudget?.consecutive ?? DEFAULT_DENIAL_BUDGET.consecutive,
5719
+ perSession: decision.denialBudget?.perSession ?? DEFAULT_DENIAL_BUDGET.perSession
5720
+ },
5721
+ warnings
5570
5722
  };
5571
5723
  }
5572
- //#endregion
5573
- //#region src/rules/split-shell-command.ts
5574
- function splitShellCommand(command) {
5575
- const segments = [];
5576
- let current = "";
5577
- let quote = null;
5578
- let unparsed = false;
5579
- let i = 0;
5580
- const collectSegment = () => {
5581
- const text = current.trim();
5582
- if (text !== "") segments.push({ text });
5583
- current = "";
5724
+ function collectScopeWarnings(entries) {
5725
+ if (entries === null) return [];
5726
+ const sources = Object.values(entries);
5727
+ return [...sources.some((source) => source.kind === "cwd") ? [] : ["has no cwd entry, so the task owns only what the other sources name"], ...sources.flatMap((source) => source.paths ?? []).filter((glob) => glob.includes(".worktrees")).map((glob) => `glob ${glob} covers worktrees that other tasks own`)];
5728
+ }
5729
+ function buildRegistry(raw, parseEntry) {
5730
+ const entries = /* @__PURE__ */ new Map();
5731
+ const dropped = [];
5732
+ const droppedIDs = [];
5733
+ for (const [id, entry] of Object.entries(raw)) {
5734
+ const result = parseEntry(id, entry);
5735
+ if (typeof result === "string") {
5736
+ dropped.push(`${id} dropped: ${result}`);
5737
+ droppedIDs.push(id);
5738
+ } else entries.set(id, result);
5739
+ }
5740
+ return {
5741
+ entries: Object.fromEntries(entries),
5742
+ dropped,
5743
+ droppedIDs
5584
5744
  };
5585
- while (i < command.length) {
5586
- const ch = command.charAt(i);
5587
- if (quote !== null) {
5588
- if (quote === "\"" && (command.startsWith("$(", i) || ch === "`")) unparsed = true;
5589
- if (ch === quote) quote = null;
5590
- current += ch;
5591
- i += 1;
5592
- continue;
5593
- }
5594
- if (ch === "'" || ch === "\"") {
5595
- quote = ch;
5596
- current += ch;
5597
- i += 1;
5598
- continue;
5599
- }
5600
- if (ch === "\\") {
5601
- current += command.slice(i, i + 2);
5602
- i += 2;
5603
- continue;
5604
- }
5605
- if (command.startsWith("$(", i) || ch === "`" || command.startsWith("<(", i) || ch === ">") {
5606
- unparsed = true;
5607
- current += ch;
5608
- i += 1;
5609
- continue;
5610
- }
5611
- if (command.startsWith("&&", i) || command.startsWith("||", i)) {
5612
- collectSegment();
5613
- i += 2;
5614
- continue;
5615
- }
5616
- if (ch === ";" || ch === "|" || ch === "\n" || ch === "&") {
5617
- collectSegment();
5618
- i += 1;
5619
- continue;
5620
- }
5621
- current += ch;
5622
- i += 1;
5745
+ }
5746
+ const CLASSIFIER_KINDS = [...Object.keys(PRESETS), "messages"];
5747
+ const classifierEntrySchema = strictObject({
5748
+ kind: text.optional(),
5749
+ baseURL: text.optional(),
5750
+ model: text.optional(),
5751
+ apiKeyEnv: text.optional(),
5752
+ apiKeyCommand: text.optional(),
5753
+ reasoning: boolean().optional(),
5754
+ maxTokens: positive.optional(),
5755
+ timeoutMs: positive.optional()
5756
+ });
5757
+ const CREDENTIAL_KEYS = /* @__PURE__ */ new Set([
5758
+ "apiKey",
5759
+ "key",
5760
+ "token",
5761
+ "secret"
5762
+ ]);
5763
+ function parseClassifierEntry(id, entry) {
5764
+ if (typeof entry === "object" && entry !== null) {
5765
+ const credential = Object.keys(entry).find((key) => CREDENTIAL_KEYS.has(key));
5766
+ if (credential !== void 0) return `holds a literal ${credential}; name the key with apiKeyEnv or apiKeyCommand`;
5623
5767
  }
5624
- collectSegment();
5768
+ const parsed = classifierEntrySchema.safeParse(entry);
5769
+ if (!parsed.success) return formatFirstIssue(parsed.error.issues);
5770
+ const kind = parsed.data.kind ?? id;
5771
+ if (!CLASSIFIER_KINDS.includes(kind)) return `unknown kind '${kind}'; known kinds are ${CLASSIFIER_KINDS.join(", ")}`;
5772
+ const base = kind === "messages" ? MESSAGES_DEFAULTS : findPreset(kind);
5773
+ const model = parsed.data.model ?? base?.model;
5774
+ if (base === void 0 || model === void 0) return `kind '${kind}' needs a model`;
5625
5775
  return {
5626
- segments,
5627
- hasUnparsedConstruct: unparsed || quote !== null
5776
+ protocol: base.protocol ?? "messages",
5777
+ baseURL: parsed.data.baseURL ?? base.baseURL,
5778
+ model,
5779
+ apiKeyEnv: parsed.data.apiKeyEnv ?? base.apiKeyEnv,
5780
+ apiKeyCommand: parsed.data.apiKeyCommand ?? base.apiKeyCommand,
5781
+ reasoning: parsed.data.reasoning ?? base.reasoning,
5782
+ maxTokens: parsed.data.maxTokens ?? base.maxTokens,
5783
+ timeoutMs: parsed.data.timeoutMs ?? base.timeoutMs
5628
5784
  };
5629
5785
  }
5630
- //#endregion
5631
- //#region src/containment/collect-scope-findings.ts
5632
- function collectScopeFindings(action, scope) {
5633
- const filePath = action.input["file_path"] ?? action.input["notebook_path"];
5634
- const command = action.input["command"];
5635
- const findings = [
5636
- ...FILE_WRITE_TOOLS.has(action.tool) && typeof filePath === "string" ? collectTargetFindings(action.cwd, filePath, scope) : [],
5637
- ...action.tool.startsWith("mcp__") ? collectToolFindings(action, scope) : [],
5638
- ...action.tool === "Bash" && typeof command === "string" ? collectCommandFindings(command, action.cwd, scope) : []
5639
- ];
5640
- return findings.filter((finding, index) => findings.findIndex((other) => other.kind === finding.kind && other.target === finding.target) === index);
5786
+ const scopeSourceEntrySchema = strictObject({
5787
+ kind: text.optional(),
5788
+ paths: array(text).min(1).optional()
5789
+ });
5790
+ function parseScopeSourceEntry(id, entry) {
5791
+ const parsed = scopeSourceEntrySchema.safeParse(entry);
5792
+ if (!parsed.success) return formatFirstIssue(parsed.error.issues);
5793
+ const kind = SCOPE_SOURCE_KINDS.find((known) => known === (parsed.data.kind ?? id));
5794
+ if (kind === void 0) return `unknown kind '${parsed.data.kind ?? id}'; known kinds are ${SCOPE_SOURCE_KINDS.join(", ")}`;
5795
+ if (kind === "globs") return parsed.data.paths === void 0 ? "kind 'globs' needs paths" : {
5796
+ kind,
5797
+ paths: parsed.data.paths.map((glob) => expandHomePath(glob) ?? glob)
5798
+ };
5799
+ return parsed.data.paths === void 0 ? { kind } : `kind '${kind}' takes no paths`;
5641
5800
  }
5642
- const FILE_WRITE_TOOLS = /* @__PURE__ */ new Set([
5643
- "Edit",
5644
- "MultiEdit",
5645
- "Write",
5646
- "NotebookEdit"
5647
- ]);
5648
- function collectTargetFindings(directory, target, scope) {
5649
- const path = resolvePath(directory, target, scope.home);
5650
- return path === null ? [] : collectPathFindings(path, scope);
5801
+ function formatFirstIssue(issues) {
5802
+ const [issue] = issues;
5803
+ if (issue === void 0) return "invalid entry";
5804
+ return issue.path.length === 0 ? issue.message : `${issue.path.map(String).join(".")}: ${issue.message}`;
5651
5805
  }
5652
- const CREDENTIAL_PATHS = [
5653
- ".ssh",
5654
- ".aws",
5655
- ".gnupg",
5656
- ".gitconfig",
5657
- ".netrc",
5658
- ".npmrc",
5659
- ".config/gh"
5660
- ];
5661
- function collectPathFindings(path, scope) {
5662
- if (CREDENTIAL_PATHS.some((entry) => isUnder(path, join(scope.home, entry)))) return [{
5663
- kind: "credential",
5664
- target: path
5665
- }];
5666
- return isInScope(path, scope) ? [] : [{
5667
- kind: "path",
5668
- target: path
5669
- }];
5806
+ function resolveRole(role, id, entries, droppedIDs, path) {
5807
+ const entry = Object.hasOwn(entries, id) ? entries[id] : void 0;
5808
+ if (entry !== void 0) return entry;
5809
+ if (droppedIDs.includes(id)) throw new Error(`${path}: decision.${role} names '${id}', whose entry was dropped`);
5810
+ if (findPreset(id) === void 0) throw new Error(`${path}: decision.${role} names '${id}', which is neither a classifiers entry nor a built-in kind (${Object.keys(PRESETS).join(", ")})`);
5811
+ return parseBuiltIn(id);
5670
5812
  }
5671
- function collectToolFindings(action, scope) {
5672
- const verb = action.tool.split("__").at(-1) ?? "";
5673
- if (!/merge|create|comment|update|delete|push|close|review/u.test(verb)) return [];
5674
- const number = action.input["pullNumber"] ?? action.input["pull_number"] ?? action.input["issue_number"];
5675
- return !verb.includes("merge") && typeof number === "number" && scope.pullRequests.includes(number) ? [] : [{
5676
- kind: "remote-write",
5677
- target: action.tool
5678
- }];
5813
+ function parseBuiltIn(id) {
5814
+ const resolved = parseClassifierEntry(id, {});
5815
+ if (typeof resolved === "string") throw new TypeError(`built-in kind ${id} does not resolve: ${resolved}`);
5816
+ return resolved;
5679
5817
  }
5680
- const PATH_WRITERS = /* @__PURE__ */ new Set([
5681
- "rm",
5682
- "rmdir",
5683
- "shred",
5684
- "truncate",
5685
- "chmod",
5686
- "chown"
5687
- ]);
5688
- const DESTINATION_WRITERS = /* @__PURE__ */ new Set([
5689
- "cp",
5690
- "mv",
5691
- "rsync",
5692
- "ln"
5693
- ]);
5694
- function collectCommandFindings(command, cwd, scope) {
5695
- const findings = [];
5696
- const hasPipe = /(?<!\|)\|(?!\|)/u.test(command);
5697
- let directory = cwd;
5698
- for (const segment of splitShellCommand(command).segments) {
5699
- const [name, ...args] = getCommandWords(splitWords$1(segment.text));
5700
- for (const target of collectRedirectTargets(segment.text)) findings.push(...collectTargetFindings(directory, target, scope));
5701
- if (name === void 0) continue;
5702
- if (name === "cd" && hasPipe) directory = null;
5703
- else if (name === "cd") directory = args[0] === void 0 ? scope.home : resolvePath(directory, args[0], scope.home);
5704
- else findings.push(...collectProgramFindings(name, args, directory, cwd, scope));
5705
- }
5706
- return findings;
5818
+ function expandHomePath(path) {
5819
+ return path?.startsWith("~/") === true ? join(homedir(), path.slice(2)) : path;
5707
5820
  }
5708
- function collectProgramFindings(name, args, directory, cwd, scope) {
5709
- const resolve = (path) => collectTargetFindings(directory, path, scope);
5710
- const operands = args.filter((word) => !word.startsWith("-"));
5711
- if (name === "git") return collectGitFindings(args, directory, cwd, scope);
5712
- if (name === "gh") return collectGhFindings(args, scope);
5713
- if (PATH_WRITERS.has(name)) return operands.flatMap((path) => resolve(path));
5714
- if (DESTINATION_WRITERS.has(name)) return collectCopyFindings(name, args, operands).flatMap((path) => resolve(path));
5715
- if (name === "find" && args.some((word) => word === "-delete" || word === "-exec")) return operands.slice(0, 1).flatMap((path) => resolve(path));
5716
- if ((name === "perl" || name === "sed") && args.some((word) => /^-\w*i/u.test(word))) return collectInPlaceFiles(args).flatMap((path) => resolve(path));
5717
- if (name === "ssh-keygen") return collectKeygenFiles(args).flatMap((path) => resolve(path));
5718
- if (name === "ssh") {
5719
- const logIndex = args.indexOf("-E");
5720
- const log = logIndex === -1 ? void 0 : args[logIndex + 1];
5721
- return [...log === void 0 ? [] : resolve(log), ...collectSSHFindings(args)];
5722
- }
5723
- if (name === "scp") {
5724
- const destination = collectSSHOperands(args).at(-1);
5725
- if (destination === void 0 || /^[^/]+:/u.test(destination)) return destination === void 0 ? [] : [{
5726
- kind: "remote-write",
5727
- target: "scp"
5728
- }];
5729
- return resolve(destination);
5730
- }
5731
- if (name === "curl") return collectCurlFindings(args);
5732
- const finding = findProgramFinding(name, args);
5733
- return finding === null ? [] : [finding];
5821
+ function resolveApiKey(provider, options = {}) {
5822
+ const fromEnv = provider.apiKeyEnv === void 0 ? void 0 : process.env[provider.apiKeyEnv];
5823
+ if (fromEnv !== void 0 && fromEnv !== "") return Promise.resolve(fromEnv);
5824
+ if (provider.apiKeyCommand === void 0 || provider.apiKeyCommand === "") return Promise.resolve(null);
5825
+ return readApiKeyFromCommand(provider.apiKeyCommand, options);
5734
5826
  }
5735
- function collectCopyFindings(name, args, operands) {
5736
- const optionIndex = args.findIndex((word) => word === "-t" || word === "--target-directory");
5737
- let targetDirectory = args.find((word) => word.startsWith("--target-directory="))?.slice(19);
5738
- if (targetDirectory === void 0 && optionIndex !== -1) targetDirectory = args[optionIndex + 1];
5739
- if (targetDirectory !== void 0) {
5740
- const sources = operands.filter((word) => word !== targetDirectory);
5741
- return [targetDirectory, ...name === "mv" ? sources : []];
5827
+ //#endregion
5828
+ //#region src/config/load-claude-rules.ts
5829
+ async function loadClaudeRules(path) {
5830
+ const empty = {
5831
+ environment: [],
5832
+ allow: [],
5833
+ soft_deny: [],
5834
+ hard_deny: []
5835
+ };
5836
+ if (path === null) return empty;
5837
+ const configDir = process.env["CLAUDE_CONFIG_DIR"];
5838
+ const base = configDir === void 0 || configDir === "" ? join(homedir(), ".claude") : configDir;
5839
+ const settingsPath = path ?? join(base, "settings.json");
5840
+ let raw;
5841
+ try {
5842
+ raw = await readFile(settingsPath, "utf8");
5843
+ } catch (error) {
5844
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return empty;
5845
+ throw new Error("Claude settings unreadable", { cause: error });
5742
5846
  }
5743
- if (operands.length < 2) return [];
5744
- return [operands.at(-1) ?? "", ...name === "mv" ? operands.slice(0, -1) : []];
5745
- }
5746
- const KEYGEN_READ_OPTIONS = /* @__PURE__ */ new Set([
5747
- "-l",
5748
- "-y",
5749
- "-F",
5750
- "-B",
5751
- "-R"
5752
- ]);
5753
- function collectKeygenFiles(args) {
5754
- if (args.some((word) => KEYGEN_READ_OPTIONS.has(word))) return [];
5755
- const index = args.indexOf("-f");
5756
- const file = index === -1 ? void 0 : args[index + 1];
5757
- return file === void 0 ? [] : [file];
5847
+ const entries = array(string()).optional();
5848
+ const schema = object({ autoMode: object({
5849
+ environment: entries,
5850
+ allow: entries,
5851
+ soft_deny: entries,
5852
+ hard_deny: entries
5853
+ }).optional() });
5854
+ let body;
5855
+ try {
5856
+ body = JSON.parse(raw);
5857
+ } catch {
5858
+ throw new Error("Claude settings contain invalid JSON");
5859
+ }
5860
+ const parsed = schema.safeParse(body);
5861
+ if (!parsed.success) throw new Error("Claude autoMode settings are invalid");
5862
+ const rules = parsed.data;
5863
+ return {
5864
+ environment: (rules.autoMode?.environment ?? []).filter((entry) => entry !== "$defaults"),
5865
+ allow: (rules.autoMode?.allow ?? []).filter((entry) => entry !== "$defaults"),
5866
+ soft_deny: (rules.autoMode?.soft_deny ?? []).filter((entry) => entry !== "$defaults"),
5867
+ hard_deny: (rules.autoMode?.hard_deny ?? []).filter((entry) => entry !== "$defaults")
5868
+ };
5758
5869
  }
5759
- const SSH_VALUE_OPTIONS = /* @__PURE__ */ new Set([
5760
- "-b",
5761
- "-c",
5762
- "-D",
5763
- "-E",
5764
- "-F",
5765
- "-i",
5766
- "-J",
5767
- "-l",
5768
- "-L",
5769
- "-m"
5770
- ]);
5771
- const SSH_MORE_VALUE_OPTIONS = /* @__PURE__ */ new Set([
5772
- "-o",
5773
- "-p",
5774
- "-P",
5775
- "-Q",
5776
- "-R",
5777
- "-S",
5778
- "-W",
5779
- "-w"
5780
- ]);
5781
- function collectSSHOperands(args) {
5782
- const operands = [];
5783
- for (let index = 0; index < args.length; index += 1) {
5784
- const word = args[index] ?? "";
5785
- if (operands.length > 0 || !word.startsWith("-")) operands.push(word);
5786
- else if (SSH_VALUE_OPTIONS.has(word) || SSH_MORE_VALUE_OPTIONS.has(word)) index += 1;
5787
- }
5788
- return operands;
5789
- }
5790
- const SSH_READS = /* @__PURE__ */ new Set([
5791
- "cat",
5792
- "df",
5793
- "du",
5794
- "free",
5795
- "head",
5796
- "id",
5797
- "ls",
5798
- "ps",
5799
- "pwd",
5800
- "stat",
5801
- "tail",
5802
- "uname",
5803
- "uptime",
5804
- "whoami"
5805
- ]);
5806
- function collectSSHFindings(args) {
5807
- const [host, ...remote] = collectSSHOperands(args);
5808
- if (host === void 0) return [];
5809
- const command = remote.join(" ");
5810
- const [program] = command.trim().split(/\s+/u);
5811
- return SSH_READS.has(program ?? "") && !/[;&|<>`\n\r]|\$\(/u.test(command) ? [] : [{
5812
- kind: "remote-write",
5813
- target: "ssh"
5814
- }];
5815
- }
5816
- const LOCAL_HOSTS = /* @__PURE__ */ new Set([
5817
- "localhost",
5818
- "127.0.0.1",
5819
- "[::1]",
5820
- "0.0.0.0"
5821
- ]);
5822
- function collectCurlFindings(args) {
5823
- const joined = args.join(" ");
5824
- const isUpload = args.some((word) => /^-[a-zA-Z]*[dFT]$|^--(?:data\S*|form|upload-file|json)$/u.test(word));
5825
- const isWriteMethod = /(?:-X\s*|--request\s+)(?:POST|PUT|PATCH|DELETE)/u.test(joined);
5826
- if (!isUpload && !isWriteMethod) return [];
5827
- const hosts = args.filter((word) => /^(?:https?:\/\/|localhost\b|127\.)/u.test(word)).map((word) => /^(?:https?:\/\/)?(?<host>\[[^\]]+\]|[^/:?#\s]+)/u.exec(word)?.groups?.["host"]?.toLowerCase() ?? "");
5828
- return hosts.length > 0 && hosts.every((host) => LOCAL_HOSTS.has(host)) ? [] : [{
5829
- kind: "remote-write",
5830
- target: "curl upload"
5831
- }];
5832
- }
5833
- const GIT_DIRECTORY_WRITES = /* @__PURE__ */ new Set([
5834
- "clean",
5835
- "reset",
5836
- "checkout",
5837
- "switch",
5838
- "merge",
5839
- "commit",
5840
- "rm",
5841
- "stash",
5842
- "push"
5843
- ]);
5844
- function collectGitFindings(args, directory, cwd, scope) {
5845
- let gitDirectory = directory;
5846
- let index = 0;
5847
- while (index < args.length && args[index]?.startsWith("-") === true) {
5848
- const option = args[index];
5849
- if (option === "-C") gitDirectory = resolvePath(gitDirectory, args[index + 1] ?? "", scope.home);
5850
- index += option === "-C" || option === "-c" ? 2 : 1;
5851
- }
5852
- const subcommand = args[index] ?? "";
5853
- const rest = args.slice(index + 1);
5854
- const operands = rest.filter((word) => !word.startsWith("-"));
5855
- const findings = [];
5856
- if (gitDirectory !== null && GIT_DIRECTORY_WRITES.has(subcommand) && !isInScope(gitDirectory, scope)) findings.push({
5857
- kind: "path",
5858
- target: gitDirectory
5859
- });
5860
- if (subcommand === "worktree" && operands[0] === "remove" && operands[1] !== void 0) findings.push(...collectTargetFindings(gitDirectory, operands[1], scope));
5861
- 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({
5862
- kind: "branch",
5863
- target: branch
5864
- });
5865
- const isConfigRead = rest.some((word) => /^--(?:get|get-all|get-regexp|list)$|^-l$/u.test(word));
5866
- if (subcommand === "config" && rest.includes("--global") && !isConfigRead) findings.push({
5867
- kind: "credential",
5868
- target: `git config ${operands[0] ?? ""}`
5869
- });
5870
- if (subcommand === "push") {
5871
- const currentBranch = gitDirectory === cwd ? scope.currentBranch : null;
5872
- findings.push(...collectPushFindings(rest, operands, currentBranch, scope));
5873
- }
5874
- return findings;
5875
- }
5876
- function collectPushFindings(rest, operands, currentBranch, scope) {
5877
- const [remote, ...refspecs] = operands;
5878
- const findings = [];
5879
- if (rest.includes("--all") || rest.includes("--mirror")) findings.push({
5880
- kind: "branch",
5881
- target: "--all"
5882
- });
5883
- if (remote !== void 0 && !isUnresolved(remote) && !isOwnedRemote(remote, scope)) findings.push({
5884
- kind: "remote-write",
5885
- target: remote
5886
- });
5887
- const destinations = refspecs.length === 0 && !rest.includes("--tags") ? ["HEAD"] : refspecs;
5888
- for (const refspec of destinations) {
5889
- const named = (refspec.split(":").at(-1) ?? refspec).replace(/^\+/u, "").replace(/^refs\/heads\//u, "");
5890
- const branch = named === "HEAD" ? currentBranch : named;
5891
- if (branch !== null && !isUnresolved(branch) && !branch.startsWith("refs/tags/") && !scope.branches.includes(branch)) findings.push({
5892
- kind: "branch",
5893
- target: branch
5894
- });
5870
+ //#endregion
5871
+ //#region src/model/load-repository-context.ts
5872
+ async function loadRepositoryContext(cwd) {
5873
+ if ([
5874
+ "GIT_DIR",
5875
+ "GIT_WORK_TREE",
5876
+ "GIT_COMMON_DIR"
5877
+ ].some((name) => process.env[name] !== void 0)) return null;
5878
+ let directory = resolve(cwd);
5879
+ for (;;) {
5880
+ const gitPath = join(directory, ".git");
5881
+ try {
5882
+ await stat(gitPath);
5883
+ } catch (error) {
5884
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) return null;
5885
+ const parent = dirname(directory);
5886
+ if (parent === directory) return null;
5887
+ directory = parent;
5888
+ continue;
5889
+ }
5890
+ let gitDir = gitPath;
5891
+ let head;
5892
+ try {
5893
+ try {
5894
+ head = await readFile(join(gitDir, "HEAD"), "utf8");
5895
+ } catch {
5896
+ const link = await readFile(gitPath, "utf8");
5897
+ const path = /^gitdir: (?<path>.+)\r?\n?$/u.exec(link)?.groups?.["path"];
5898
+ if (path === void 0) return null;
5899
+ gitDir = resolve(directory, path.trim());
5900
+ head = await readFile(join(gitDir, "HEAD"), "utf8");
5901
+ }
5902
+ } catch {
5903
+ return null;
5904
+ }
5905
+ let commonDir = gitDir;
5906
+ try {
5907
+ const common = await readFile(join(gitDir, "commondir"), "utf8");
5908
+ commonDir = resolve(gitDir, common.trim());
5909
+ } catch {
5910
+ commonDir = gitDir;
5911
+ }
5912
+ let defaultBranch = null;
5913
+ try {
5914
+ defaultBranch = parseReference(await readFile(join(commonDir, "refs", "remotes", "origin", "HEAD"), "utf8"), "refs/remotes/origin/");
5915
+ } catch {
5916
+ defaultBranch = null;
5917
+ }
5918
+ return {
5919
+ cwd: resolve(cwd),
5920
+ branch: parseReference(head, "refs/heads/"),
5921
+ defaultBranch
5922
+ };
5895
5923
  }
5896
- return findings;
5897
- }
5898
- function isUnresolved(word) {
5899
- return /[$`]/u.test(word);
5900
5924
  }
5901
- function isOwnedRemote(remote, scope) {
5902
- if (scope.remotes.some((entry) => entry.name === remote)) return true;
5903
- const slug = toRepositorySlug(remote);
5904
- return slug !== null && scope.remotes.some((entry) => toRepositorySlug(entry.url) === slug);
5925
+ function parseReference(content, prefix) {
5926
+ const value = content.trim();
5927
+ const marker = `ref: ${prefix}`;
5928
+ if (!value.startsWith(marker)) return null;
5929
+ const branch = value.slice(marker.length);
5930
+ return branch !== "" && branch.length <= 256 && !/\s/u.test(branch) ? branch : null;
5905
5931
  }
5932
+ //#endregion
5933
+ //#region src/containment/to-repository-slug.ts
5906
5934
  function toRepositorySlug(reference) {
5907
5935
  const match = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?(?<host>[^/:]+)[:/](?<owner>[^/]+)\/(?<name>[^/]+?)(?:\.git)?\/?$/iu.exec(reference);
5908
5936
  if (match?.groups === void 0) return null;
5909
5937
  const parts = match.groups;
5910
5938
  return `${parts["host"] ?? ""}/${parts["owner"] ?? ""}/${parts["name"] ?? ""}`.toLowerCase();
5911
5939
  }
5912
- const GH_PR_WRITES = /* @__PURE__ */ new Set([
5913
- "comment",
5914
- "merge",
5915
- "edit",
5916
- "close",
5917
- "review",
5918
- "ready",
5919
- "reopen"
5920
- ]);
5921
- const GH_REMOTE_WRITES = /* @__PURE__ */ new Set([
5922
- "gist",
5923
- "release",
5924
- "repo",
5925
- "ruleset",
5926
- "label",
5927
- "workflow"
5928
- ]);
5929
- const GH_CREDENTIAL_WRITES = /* @__PURE__ */ new Set([
5930
- "secret",
5931
- "variable",
5932
- "ssh-key",
5933
- "gpg-key"
5934
- ]);
5935
- const GH_READS = /* @__PURE__ */ new Set([
5936
- "list",
5937
- "view",
5938
- "clone",
5939
- "status",
5940
- "checks",
5941
- "diff"
5940
+ //#endregion
5941
+ //#region src/scope/merge-scope-facts.ts
5942
+ function mergeScopeFacts(all) {
5943
+ const pullRequests = /* @__PURE__ */ new Map();
5944
+ for (const pull of all.flatMap((facts) => facts.pullRequests)) pullRequests.set(`${pull.repository}#${String(pull.number)}`, pull);
5945
+ return {
5946
+ worktrees: [...new Set(all.flatMap((facts) => facts.worktrees))],
5947
+ branches: [...new Set(all.flatMap((facts) => facts.branches))],
5948
+ pullRequests: [...pullRequests.values()],
5949
+ pathGlobs: [...new Set(all.flatMap((facts) => facts.pathGlobs))]
5950
+ };
5951
+ }
5952
+ //#endregion
5953
+ //#region src/scope/build-task-scope.ts
5954
+ function buildTaskScope(input) {
5955
+ const merged = mergeScopeFacts(input.facts);
5956
+ const branches = merged.branches.filter((branch) => branch !== input.defaultBranch && !(input.defaultBranch === null && DEFAULT_NAMES.has(branch)));
5957
+ const repositories = new Set(input.remotes.map((remote) => toRepositorySlug(remote.url)));
5958
+ const pullRequests = merged.pullRequests.filter((pull) => branches.includes(pull.head) && repositories.has(pull.repository)).map((pull) => ({
5959
+ number: pull.number,
5960
+ repository: pull.repository
5961
+ }));
5962
+ return {
5963
+ home: input.home,
5964
+ worktrees: merged.worktrees,
5965
+ branches,
5966
+ currentBranch: input.currentBranch,
5967
+ remotes: input.remotes,
5968
+ pullRequests,
5969
+ pathGlobs: merged.pathGlobs
5970
+ };
5971
+ }
5972
+ const DEFAULT_NAMES = /* @__PURE__ */ new Set([
5973
+ "main",
5974
+ "master",
5975
+ "trunk",
5976
+ "develop"
5942
5977
  ]);
5943
- function collectGhFindings(args, scope) {
5944
- const group = args[0] ?? "";
5945
- const verb = args[1] ?? "";
5946
- if (group === "pr" && GH_PR_WRITES.has(verb)) {
5947
- const number = Number(args.slice(2).find((word) => /^\d+$/u.test(word)));
5948
- const repoIndex = args.findIndex((word) => word === "--repo" || word === "-R");
5949
- const repo = repoIndex === -1 ? void 0 : args[repoIndex + 1];
5950
- return verb !== "merge" && scope.pullRequests.includes(number) && (repo === void 0 || isCheckoutRepository(repo, scope)) ? [] : [{
5951
- kind: "remote-write",
5952
- target: `gh pr ${verb}`
5953
- }];
5954
- }
5955
- if (group === "issue" && verb !== "" && !GH_READS.has(verb)) return [{
5956
- kind: "remote-write",
5957
- target: `gh issue ${verb}`
5958
- }];
5959
- if (group === "api") return collectGhAPIFindings(args.slice(1), scope);
5960
- if (group === "auth" && [
5961
- "refresh",
5962
- "login",
5963
- "logout"
5964
- ].includes(verb)) return [{
5965
- kind: "credential",
5966
- target: `gh auth ${verb}`
5967
- }];
5968
- if (GH_CREDENTIAL_WRITES.has(group) && !GH_READS.has(verb)) return [{
5969
- kind: "credential",
5970
- target: `gh ${group} ${verb}`
5971
- }];
5972
- if (GH_REMOTE_WRITES.has(group) && !GH_READS.has(verb)) return [{
5973
- kind: "remote-write",
5974
- target: `gh ${group} ${verb}`
5975
- }];
5976
- return [];
5977
- }
5978
- function isCheckoutRepository(repo, scope) {
5979
- const wanted = repo.toLowerCase().replace(/\.git$/u, "");
5980
- return scope.remotes.some((remote) => {
5981
- const slug = toRepositorySlug(remote.url);
5982
- return slug !== null && (slug === wanted || slug.endsWith(`/${wanted}`));
5983
- });
5978
+ //#endregion
5979
+ //#region src/scope/find-checkout.ts
5980
+ const GIT_OVERRIDES = [
5981
+ "GIT_DIR",
5982
+ "GIT_WORK_TREE",
5983
+ "GIT_COMMON_DIR"
5984
+ ];
5985
+ async function findCheckout(start) {
5986
+ if (GIT_OVERRIDES.some((name) => process.env[name] !== void 0)) return null;
5987
+ for (let directory = start;; directory = dirname(directory)) {
5988
+ const gitPath = join(directory, ".git");
5989
+ const info = await stat(gitPath).catch(() => null);
5990
+ if (info !== null) {
5991
+ const gitDir = info.isDirectory() ? gitPath : await findLinkedGitDir(directory, gitPath);
5992
+ return gitDir === null ? null : {
5993
+ worktree: directory,
5994
+ commonDir: await findCommonDir(gitDir)
5995
+ };
5996
+ }
5997
+ if (dirname(directory) === directory) return null;
5998
+ }
5984
5999
  }
5985
- function collectGhAPIFindings(args, scope) {
5986
- const endpoint = args.find((word) => !word.startsWith("-")) ?? "";
5987
- if (endpoint === "graphql") return args.some((word) => /\bmutation\b/u.test(word)) ? [{
5988
- kind: "remote-write",
5989
- target: "gh api graphql mutation"
5990
- }] : [];
5991
- const methodIndex = args.findIndex((word) => word === "-X" || word === "--method");
5992
- const method = methodIndex === -1 ? null : (args[methodIndex + 1] ?? "").toUpperCase();
5993
- const hasFields = args.some((word) => /^(?:-f|-F|--field|--raw-field|--input)$/u.test(word));
5994
- if (method === "GET" || method === null && !hasFields) return [];
5995
- const pull = /^\/?repos\/(?<repo>[^/]+\/[^/]+)\/(?:pulls|issues)\/(?<number>\d+)(?:\/|$)/u.exec(endpoint)?.groups;
5996
- if (pull !== void 0 && scope.pullRequests.includes(Number(pull["number"])) && isCheckoutRepository(pull["repo"] ?? "", scope) && !endpoint.includes("/merge")) return [];
5997
- return [{
5998
- kind: /secret|collaborator|protection|key|permission/u.test(endpoint) ? "credential" : "remote-write",
5999
- target: `gh api ${endpoint}`
6000
- }];
6000
+ async function findLinkedGitDir(directory, gitPath) {
6001
+ const link = await readFile(gitPath, "utf8").catch(() => "");
6002
+ const path = /^gitdir: (?<path>.+)$/mu.exec(link)?.groups?.["path"];
6003
+ return path === void 0 ? null : resolve(directory, path.trim());
6001
6004
  }
6002
- const IAM_WRITE = /^(?:add|attach|change|create|deactivate|delete|detach|enable|put|remove|reset|set|tag|untag|update|upload)-/u;
6003
- function findProgramFinding(name, args) {
6004
- const joined = args.join(" ");
6005
- if (name === "op" && /^(?:item|vault|document) (?:edit|create|delete)\b/u.test(joined)) return {
6006
- kind: "credential",
6007
- target: `op ${args[0] ?? ""} ${args[1] ?? ""}`
6008
- };
6009
- if (name === "aws" && args[0] === "iam" && IAM_WRITE.test(args[1] ?? "")) return {
6010
- kind: "credential",
6011
- target: `aws iam ${args[1] ?? ""}`
6012
- };
6013
- if (name === "docker" && /\b(?:volume|system|image|container) prune\b|\bvolume rm\b|\bdown\b.*(?:-v|--volumes)\b/u.test(joined)) return {
6014
- kind: "prune",
6015
- target: `docker ${joined}`
6016
- };
6017
- if (name === "npm" && args[0] === "publish") return {
6018
- kind: "remote-write",
6019
- target: "npm publish"
6020
- };
6021
- if (name === "kubectl" && /\b(?:apply|delete|replace|patch|scale)\b/u.test(joined)) return {
6022
- kind: "remote-write",
6023
- target: "kubectl write"
6024
- };
6025
- if (name === "terraform" && /\b(?:apply|destroy|import)\b/u.test(joined)) return {
6026
- kind: "remote-write",
6027
- target: "terraform write"
6028
- };
6029
- if (name === "psql" && /\b(?:UPDATE|DELETE|DROP|INSERT|TRUNCATE|ALTER)\b/iu.test(joined)) return {
6030
- kind: "remote-write",
6031
- target: "database write"
6032
- };
6033
- if (/^(?:bun|npm|pnpm|yarn)$/u.test(name) && /\bdeploy\b/u.test(joined) && /prod/u.test(joined)) return {
6034
- kind: "remote-write",
6035
- target: "production deploy"
6036
- };
6037
- return null;
6005
+ async function findCommonDir(gitDir) {
6006
+ const common = await readFile(join(gitDir, "commondir"), "utf8").catch(() => null);
6007
+ return common === null ? gitDir : resolve(gitDir, common.trim());
6038
6008
  }
6039
- function collectRedirectTargets(text) {
6040
- const targets = [];
6041
- let quote = null;
6042
- let index = 0;
6043
- while (index < text.length) {
6044
- const ch = text.charAt(index);
6045
- if (quote !== null) {
6046
- quote = ch === quote ? null : quote;
6047
- index += 1;
6048
- } else if (ch === "'" || ch === "\"") {
6049
- quote = ch;
6050
- index += 1;
6051
- } else if (ch === ">" && text.charAt(index - 1) !== "<") {
6052
- let next = index + 1;
6053
- if (text.charAt(next) === ">" || text.charAt(next) === "|") next += 1;
6054
- if (text.charAt(next) === "&") {
6055
- index = next + 1;
6056
- continue;
6057
- }
6058
- const [target, end] = readWord(text, next);
6059
- if (target !== "") targets.push(target);
6060
- index = end;
6061
- } else index += 1;
6009
+ //#endregion
6010
+ //#region src/scope/load-checkout-remotes.ts
6011
+ async function loadCheckoutRemotes(commonDir) {
6012
+ const config = await readFile(join(commonDir, "config"), "utf8").catch(() => "");
6013
+ const remotes = [];
6014
+ let section = null;
6015
+ for (const line of config.split("\n")) {
6016
+ const header = /^\s*\[remote "(?<name>[^"]+)"\]/u.exec(line)?.groups?.["name"];
6017
+ if (header !== void 0) section = header;
6018
+ else if (/^\s*\[/u.test(line)) section = null;
6019
+ else if (section !== null) {
6020
+ const url = /^\s*(?:push)?url\s*=\s*(?<url>\S+)/u.exec(line)?.groups?.["url"];
6021
+ if (url !== void 0) remotes.push({
6022
+ name: section,
6023
+ url
6024
+ });
6025
+ }
6062
6026
  }
6063
- return targets;
6027
+ return remotes;
6064
6028
  }
6065
- function readWord(text, start) {
6066
- let index = start;
6067
- while (/\s/u.test(text.charAt(index))) index += 1;
6068
- let word = "";
6069
- let quote = null;
6070
- while (index < text.length) {
6071
- const ch = text.charAt(index);
6072
- if (quote !== null) {
6073
- if (ch === quote) quote = null;
6074
- else word += ch;
6075
- } else if (ch === "'" || ch === "\"") quote = ch;
6076
- else if (/[\s;&|<>()]/u.test(ch)) break;
6077
- else word += ch;
6078
- index += 1;
6029
+ //#endregion
6030
+ //#region src/scope/types.ts
6031
+ const EMPTY_SCOPE_FACTS = {
6032
+ worktrees: [],
6033
+ branches: [],
6034
+ pullRequests: [],
6035
+ pathGlobs: []
6036
+ };
6037
+ const EMPTY_SESSION_SCOPE = {
6038
+ worktrees: [],
6039
+ branches: [],
6040
+ pullRequests: []
6041
+ };
6042
+ //#endregion
6043
+ //#region src/scope/load-session-scope.ts
6044
+ const name = string().min(1);
6045
+ const pullRequestSchema = strictObject({
6046
+ number: number().int().positive(),
6047
+ head: name,
6048
+ repository: name
6049
+ });
6050
+ const branchSchema = strictObject({
6051
+ name,
6052
+ commonDir: name
6053
+ });
6054
+ const sessionScopeSchema = strictObject({
6055
+ worktrees: array(name),
6056
+ branches: array(branchSchema),
6057
+ pullRequests: array(pullRequestSchema)
6058
+ });
6059
+ async function loadSessionScope(path) {
6060
+ let raw;
6061
+ try {
6062
+ raw = await readFile(path, "utf8");
6063
+ } catch {
6064
+ return EMPTY_SESSION_SCOPE;
6079
6065
  }
6080
- return [word, index];
6081
- }
6082
- function collectInPlaceFiles(args) {
6083
- const files = [];
6084
- let skipNext = false;
6085
- let sawScript = false;
6086
- for (const word of args) if (skipNext) {
6087
- skipNext = false;
6088
- sawScript = true;
6089
- } else if (/^-\w*e$/u.test(word)) skipNext = true;
6090
- else if (!word.startsWith("-")) {
6091
- if (sawScript) files.push(word);
6092
- else sawScript = true;
6066
+ try {
6067
+ const parsed = sessionScopeSchema.safeParse(JSON.parse(raw));
6068
+ return parsed.success ? parsed.data : EMPTY_SESSION_SCOPE;
6069
+ } catch {
6070
+ return EMPTY_SESSION_SCOPE;
6093
6071
  }
6094
- return files;
6095
6072
  }
6096
- function getCommandWords(words) {
6073
+ //#endregion
6074
+ //#region src/scope/resolve-session-scope-path.ts
6075
+ function resolveSessionScopePath(stateDir, sessionID) {
6076
+ const key = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
6077
+ return join(stateDir, "session-scope", `${key}.json`);
6078
+ }
6079
+ //#endregion
6080
+ //#region src/scope/pick-scope-source-reader.ts
6081
+ function pickScopeSourceReader(source) {
6082
+ if (source.kind === "cwd") return (context) => Promise.resolve(buildFacts({
6083
+ worktrees: [context.worktree],
6084
+ branches: context.branch === null ? [] : [context.branch]
6085
+ }));
6086
+ if (source.kind === "session") return async (context) => {
6087
+ const scope = await loadSessionScope(resolveSessionScopePath(context.stateDir, context.sessionID));
6088
+ return buildFacts({
6089
+ worktrees: scope.worktrees,
6090
+ branches: scope.branches.filter((branch) => branch.commonDir === context.commonDir).map((branch) => branch.name),
6091
+ pullRequests: scope.pullRequests
6092
+ });
6093
+ };
6094
+ if (source.kind === "globs") return () => Promise.resolve(buildFacts({ pathGlobs: source.paths ?? [] }));
6095
+ return () => Promise.resolve(EMPTY_SCOPE_FACTS);
6096
+ }
6097
+ function buildFacts(facts) {
6098
+ return {
6099
+ ...EMPTY_SCOPE_FACTS,
6100
+ ...facts
6101
+ };
6102
+ }
6103
+ //#endregion
6104
+ //#region src/scope/load-task-scope.ts
6105
+ async function loadTaskScope(request, sources) {
6106
+ const cwd = resolve(request.cwd);
6107
+ const [checkout, context] = await Promise.all([findCheckout(cwd), loadRepositoryContext(cwd)]);
6108
+ const branch = context?.branch ?? null;
6109
+ const sourceContext = {
6110
+ sessionID: request.sessionID,
6111
+ cwd,
6112
+ worktree: checkout?.worktree ?? cwd,
6113
+ commonDir: checkout?.commonDir ?? null,
6114
+ branch,
6115
+ stateDir: request.stateDir
6116
+ };
6117
+ const [remotes, ...facts] = await Promise.all([checkout === null ? [] : loadCheckoutRemotes(checkout.commonDir), ...Object.values(sources).map((source) => pickScopeSourceReader(source)(sourceContext))]);
6118
+ return buildTaskScope({
6119
+ home: homedir(),
6120
+ currentBranch: branch,
6121
+ defaultBranch: context?.defaultBranch ?? null,
6122
+ remotes,
6123
+ facts
6124
+ });
6125
+ }
6126
+ //#endregion
6127
+ //#region src/state/resolve-state-dir.ts
6128
+ function resolveStateDir() {
6129
+ const stateHome = process.env["XDG_STATE_HOME"];
6130
+ const base = stateHome === void 0 || stateHome === "" ? join(homedir(), ".local", "state") : stateHome;
6131
+ return join(base, "auto-mode");
6132
+ }
6133
+ //#endregion
6134
+ //#region src/rules/split-command-words.ts
6135
+ function splitCommandWords(text) {
6136
+ const words = splitWords$1(text);
6097
6137
  let index = 0;
6098
6138
  while (index < words.length) {
6099
6139
  const word = words[index] ?? "";
@@ -6128,490 +6168,595 @@ function splitWords$1(text) {
6128
6168
  if (started) words.push(current);
6129
6169
  return words;
6130
6170
  }
6131
- function resolvePath(cwd, path, home) {
6132
- const expanded = path.replace(/^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/u, home);
6133
- if (/[$`]|^~/u.test(expanded) || expanded === "") return null;
6134
- if (cwd === null && !isAbsolute(expanded)) return null;
6135
- const absolute = isAbsolute(expanded) || cwd === null ? expanded : join(cwd, expanded);
6136
- return normalize(absolute).replace(/(?<=.)\/$/u, "");
6137
- }
6138
- const SCRATCH_PATHS = [
6139
- "/tmp",
6140
- "/dev/null",
6141
- "/dev/stdout",
6142
- "/dev/stderr"
6143
- ];
6144
- function isInScope(path, scope) {
6145
- if (SCRATCH_PATHS.some((entry) => isUnder(path, entry))) return true;
6146
- return scope.worktrees.some((worktree) => {
6147
- const isNested = isUnder(path, join(worktree, ".worktrees"));
6148
- const isOtherOwned = scope.worktrees.some((other) => other !== worktree && isUnder(path, other));
6149
- return isUnder(path, worktree) && (!isNested || isOtherOwned);
6150
- });
6151
- }
6152
- function isUnder(path, root) {
6153
- return path === root || path.startsWith(`${root}/`);
6154
- }
6155
6171
  //#endregion
6156
- //#region src/model/load-repository-context.ts
6157
- async function loadRepositoryContext(cwd) {
6158
- if ([
6159
- "GIT_DIR",
6160
- "GIT_WORK_TREE",
6161
- "GIT_COMMON_DIR"
6162
- ].some((name) => process.env[name] !== void 0)) return null;
6163
- let directory = resolve(cwd);
6164
- for (;;) {
6165
- const gitPath = join(directory, ".git");
6166
- try {
6167
- await stat(gitPath);
6168
- } catch (error) {
6169
- if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) return null;
6170
- const parent = dirname(directory);
6171
- if (parent === directory) return null;
6172
- directory = parent;
6173
- continue;
6174
- }
6175
- let gitDir = gitPath;
6176
- let head;
6177
- try {
6178
- try {
6179
- head = await readFile(join(gitDir, "HEAD"), "utf8");
6180
- } catch {
6181
- const link = await readFile(gitPath, "utf8");
6182
- const path = /^gitdir: (?<path>.+)\r?\n?$/u.exec(link)?.groups?.["path"];
6183
- if (path === void 0) return null;
6184
- gitDir = resolve(directory, path.trim());
6185
- head = await readFile(join(gitDir, "HEAD"), "utf8");
6186
- }
6187
- } catch {
6188
- return null;
6189
- }
6190
- let commonDir = gitDir;
6191
- try {
6192
- const common = await readFile(join(gitDir, "commondir"), "utf8");
6193
- commonDir = resolve(gitDir, common.trim());
6194
- } catch {
6195
- commonDir = gitDir;
6172
+ //#region src/rules/split-shell-command.ts
6173
+ function splitShellCommand(command) {
6174
+ const segments = [];
6175
+ let current = "";
6176
+ let quote = null;
6177
+ let unparsed = false;
6178
+ let i = 0;
6179
+ const collectSegment = () => {
6180
+ const text = current.trim();
6181
+ if (text !== "") segments.push({ text });
6182
+ current = "";
6183
+ };
6184
+ while (i < command.length) {
6185
+ const ch = command.charAt(i);
6186
+ if (quote !== null) {
6187
+ if (quote === "\"" && (command.startsWith("$(", i) || ch === "`")) unparsed = true;
6188
+ if (ch === quote) quote = null;
6189
+ current += ch;
6190
+ i += 1;
6191
+ continue;
6196
6192
  }
6197
- let defaultBranch = null;
6198
- try {
6199
- defaultBranch = parseReference(await readFile(join(commonDir, "refs", "remotes", "origin", "HEAD"), "utf8"), "refs/remotes/origin/");
6200
- } catch {
6201
- defaultBranch = null;
6193
+ if (ch === "'" || ch === "\"") {
6194
+ quote = ch;
6195
+ current += ch;
6196
+ i += 1;
6197
+ continue;
6202
6198
  }
6203
- return {
6204
- cwd: resolve(cwd),
6205
- branch: parseReference(head, "refs/heads/"),
6206
- defaultBranch
6207
- };
6199
+ if (ch === "\\") {
6200
+ current += command.slice(i, i + 2);
6201
+ i += 2;
6202
+ continue;
6203
+ }
6204
+ if (command.startsWith("$(", i) || ch === "`" || command.startsWith("<(", i) || ch === ">") {
6205
+ unparsed = true;
6206
+ current += ch;
6207
+ i += 1;
6208
+ continue;
6209
+ }
6210
+ if (command.startsWith("&&", i) || command.startsWith("||", i)) {
6211
+ collectSegment();
6212
+ i += 2;
6213
+ continue;
6214
+ }
6215
+ if (ch === ";" || ch === "|" || ch === "\n" || ch === "&") {
6216
+ collectSegment();
6217
+ i += 1;
6218
+ continue;
6219
+ }
6220
+ current += ch;
6221
+ i += 1;
6208
6222
  }
6209
- }
6210
- function parseReference(content, prefix) {
6211
- const value = content.trim();
6212
- const marker = `ref: ${prefix}`;
6213
- if (!value.startsWith(marker)) return null;
6214
- const branch = value.slice(marker.length);
6215
- return branch !== "" && branch.length <= 256 && !/\s/u.test(branch) ? branch : null;
6216
- }
6217
- //#endregion
6218
- //#region src/containment/build-cwd-scope.ts
6219
- function buildCWDScope(input) {
6220
- const isDefault = input.branch === input.defaultBranch || input.defaultBranch === null && DEFAULT_NAMES.has(input.branch ?? "");
6223
+ collectSegment();
6221
6224
  return {
6222
- home: input.home,
6223
- worktrees: [input.worktree],
6224
- branches: input.branch !== null && !isDefault ? [input.branch] : [],
6225
- currentBranch: input.branch,
6226
- remotes: input.remotes,
6227
- pullRequests: []
6225
+ segments,
6226
+ hasUnparsedConstruct: unparsed || quote !== null
6228
6227
  };
6229
6228
  }
6230
- const DEFAULT_NAMES = /* @__PURE__ */ new Set([
6231
- "main",
6232
- "master",
6233
- "trunk",
6234
- "develop"
6235
- ]);
6236
6229
  //#endregion
6237
- //#region src/containment/load-task-scope.ts
6238
- async function loadTaskScope(cwd) {
6239
- const [checkout, context] = await Promise.all([findCheckout(resolve(cwd)), loadRepositoryContext(cwd)]);
6240
- return buildCWDScope({
6241
- home: homedir(),
6242
- worktree: checkout?.worktree ?? resolve(cwd),
6243
- branch: context?.branch ?? null,
6244
- defaultBranch: context?.defaultBranch ?? null,
6245
- remotes: checkout === null ? [] : await readRemotes(checkout.commonDir)
6246
- });
6230
+ //#region src/containment/collect-scope-findings.ts
6231
+ function collectScopeFindings(action, scope) {
6232
+ const filePath = action.input["file_path"] ?? action.input["notebook_path"];
6233
+ const command = action.input["command"];
6234
+ const findings = [
6235
+ ...FILE_WRITE_TOOLS.has(action.tool) && typeof filePath === "string" ? collectTargetFindings(action.cwd, filePath, scope) : [],
6236
+ ...action.tool.startsWith("mcp__") ? collectToolFindings(action, scope) : [],
6237
+ ...action.tool === "Bash" && typeof command === "string" ? collectCommandFindings(command, action.cwd, scope) : []
6238
+ ];
6239
+ return findings.filter((finding, index) => findings.findIndex((other) => other.kind === finding.kind && other.target === finding.target) === index);
6247
6240
  }
6248
- const GIT_OVERRIDES = [
6249
- "GIT_DIR",
6250
- "GIT_WORK_TREE",
6251
- "GIT_COMMON_DIR"
6241
+ const FILE_WRITE_TOOLS = /* @__PURE__ */ new Set([
6242
+ "Edit",
6243
+ "MultiEdit",
6244
+ "Write",
6245
+ "NotebookEdit"
6246
+ ]);
6247
+ function collectTargetFindings(directory, target, scope) {
6248
+ const path = resolvePath(directory, target, scope.home);
6249
+ return path === null ? [] : collectPathFindings(path, scope);
6250
+ }
6251
+ const CREDENTIAL_PATHS = [
6252
+ ".ssh",
6253
+ ".aws",
6254
+ ".gnupg",
6255
+ ".gitconfig",
6256
+ ".netrc",
6257
+ ".npmrc",
6258
+ ".config/gh"
6252
6259
  ];
6253
- async function findCheckout(start) {
6254
- if (GIT_OVERRIDES.some((name) => process.env[name] !== void 0)) return null;
6255
- for (let directory = start;; directory = dirname(directory)) {
6256
- const gitPath = join(directory, ".git");
6257
- const info = await stat(gitPath).catch(() => null);
6258
- if (info !== null) {
6259
- const gitDir = info.isDirectory() ? gitPath : await findLinkedGitDir(directory, gitPath);
6260
- return gitDir === null ? null : {
6261
- worktree: directory,
6262
- commonDir: await findCommonDir(gitDir)
6263
- };
6264
- }
6265
- if (dirname(directory) === directory) return null;
6266
- }
6260
+ function collectPathFindings(path, scope) {
6261
+ if (CREDENTIAL_PATHS.some((entry) => isUnder(path, join(scope.home, entry)))) return [{
6262
+ kind: "credential",
6263
+ target: path
6264
+ }];
6265
+ return isInScope(path, scope) ? [] : [{
6266
+ kind: "path",
6267
+ target: path
6268
+ }];
6267
6269
  }
6268
- async function findLinkedGitDir(directory, gitPath) {
6269
- const link = await readFile(gitPath, "utf8").catch(() => "");
6270
- const path = /^gitdir: (?<path>.+)$/mu.exec(link)?.groups?.["path"];
6271
- return path === void 0 ? null : resolve(directory, path.trim());
6270
+ function collectToolFindings(action, scope) {
6271
+ const verb = action.tool.split("__").at(-1) ?? "";
6272
+ if (!/merge|create|comment|update|delete|push|close|review/u.test(verb)) return [];
6273
+ const number = action.input["pullNumber"] ?? action.input["pull_number"] ?? action.input["issue_number"];
6274
+ const owner = action.input["owner"];
6275
+ const name = action.input["repo"];
6276
+ const repo = typeof owner === "string" && typeof name === "string" ? `${owner}/${name}` : void 0;
6277
+ return !verb.includes("merge") && typeof number === "number" && isOwnedPullRequest(number, repo, scope) ? [] : [{
6278
+ kind: "remote-write",
6279
+ target: action.tool
6280
+ }];
6272
6281
  }
6273
- async function findCommonDir(gitDir) {
6274
- const common = await readFile(join(gitDir, "commondir"), "utf8").catch(() => null);
6275
- return common === null ? gitDir : resolve(gitDir, common.trim());
6282
+ const PATH_WRITERS = /* @__PURE__ */ new Set([
6283
+ "rm",
6284
+ "rmdir",
6285
+ "shred",
6286
+ "truncate",
6287
+ "chmod",
6288
+ "chown"
6289
+ ]);
6290
+ const DESTINATION_WRITERS = /* @__PURE__ */ new Set([
6291
+ "cp",
6292
+ "mv",
6293
+ "rsync",
6294
+ "ln"
6295
+ ]);
6296
+ function collectCommandFindings(command, cwd, scope) {
6297
+ const findings = [];
6298
+ const hasPipe = /(?<!\|)\|(?!\|)/u.test(command);
6299
+ let directory = cwd;
6300
+ for (const segment of splitShellCommand(command).segments) {
6301
+ const [name, ...args] = splitCommandWords(segment.text);
6302
+ for (const target of collectRedirectTargets(segment.text)) findings.push(...collectTargetFindings(directory, target, scope));
6303
+ if (name === void 0) continue;
6304
+ if (name === "cd" && hasPipe) directory = null;
6305
+ else if (name === "cd") directory = args[0] === void 0 ? scope.home : resolvePath(directory, args[0], scope.home);
6306
+ else findings.push(...collectProgramFindings(name, args, directory, cwd, scope));
6307
+ }
6308
+ return findings;
6276
6309
  }
6277
- async function readRemotes(commonDir) {
6278
- const config = await readFile(join(commonDir, "config"), "utf8").catch(() => "");
6279
- const remotes = [];
6280
- let section = null;
6281
- for (const line of config.split("\n")) {
6282
- const header = /^\s*\[remote "(?<name>[^"]+)"\]/u.exec(line)?.groups?.["name"];
6283
- if (header !== void 0) section = header;
6284
- else if (/^\s*\[/u.test(line)) section = null;
6285
- else if (section !== null) {
6286
- const url = /^\s*(?:push)?url\s*=\s*(?<url>\S+)/u.exec(line)?.groups?.["url"];
6287
- if (url !== void 0) remotes.push({
6288
- name: section,
6289
- url
6290
- });
6291
- }
6310
+ function collectProgramFindings(name, args, directory, cwd, scope) {
6311
+ const resolve = (path) => collectTargetFindings(directory, path, scope);
6312
+ const operands = args.filter((word) => !word.startsWith("-"));
6313
+ if (name === "git") return collectGitFindings(args, directory, cwd, scope);
6314
+ if (name === "gh") return collectGhFindings(args, scope);
6315
+ if (PATH_WRITERS.has(name)) return operands.flatMap((path) => resolve(path));
6316
+ if (DESTINATION_WRITERS.has(name)) return collectCopyFindings(name, args, operands).flatMap((path) => resolve(path));
6317
+ if (name === "find" && args.some((word) => word === "-delete" || word === "-exec")) return operands.slice(0, 1).flatMap((path) => resolve(path));
6318
+ if ((name === "perl" || name === "sed") && args.some((word) => /^-\w*i/u.test(word))) return collectInPlaceFiles(args).flatMap((path) => resolve(path));
6319
+ if (name === "ssh-keygen") return collectKeygenFiles(args).flatMap((path) => resolve(path));
6320
+ if (name === "ssh") {
6321
+ const logIndex = args.indexOf("-E");
6322
+ const log = logIndex === -1 ? void 0 : args[logIndex + 1];
6323
+ return [...log === void 0 ? [] : resolve(log), ...collectSSHFindings(args)];
6292
6324
  }
6293
- return remotes;
6325
+ if (name === "scp") {
6326
+ const destination = collectSSHOperands(args).at(-1);
6327
+ if (destination === void 0 || /^[^/]+:/u.test(destination)) return destination === void 0 ? [] : [{
6328
+ kind: "remote-write",
6329
+ target: "scp"
6330
+ }];
6331
+ return resolve(destination);
6332
+ }
6333
+ if (name === "curl") return collectCurlFindings(args);
6334
+ const launcher = [name, ...args].findIndex((word) => word.includes("auto-mode"));
6335
+ if (launcher !== -1 && [name, ...args].slice(launcher + 1).includes("record")) return [{
6336
+ kind: "credential",
6337
+ target: "auto-mode record"
6338
+ }];
6339
+ const finding = findProgramFinding(name, args);
6340
+ return finding === null ? [] : [finding];
6294
6341
  }
6295
- //#endregion
6296
- //#region src/containment/check-containment.ts
6297
- const CONTAINMENT_RULE = "Outside Task Scope";
6298
- async function checkContainment(request) {
6299
- let scope;
6300
- try {
6301
- scope = await loadTaskScope(request.cwd);
6302
- } catch {
6303
- return null;
6342
+ function collectCopyFindings(name, args, operands) {
6343
+ const optionIndex = args.findIndex((word) => word === "-t" || word === "--target-directory");
6344
+ let targetDirectory = args.find((word) => word.startsWith("--target-directory="))?.slice(19);
6345
+ if (targetDirectory === void 0 && optionIndex !== -1) targetDirectory = args[optionIndex + 1];
6346
+ if (targetDirectory !== void 0) {
6347
+ const sources = operands.filter((word) => word !== targetDirectory);
6348
+ return [targetDirectory, ...name === "mv" ? sources : []];
6304
6349
  }
6305
- const findings = collectScopeFindings({
6306
- tool: request.toolName,
6307
- cwd: request.cwd,
6308
- input: request.toolInput
6309
- }, scope);
6310
- if (findings.length === 0) return null;
6311
- return {
6312
- rule: CONTAINMENT_RULE,
6313
- reason: formatContainmentReason(findings, scope),
6314
- findings
6315
- };
6350
+ if (operands.length < 2) return [];
6351
+ return [operands.at(-1) ?? "", ...name === "mv" ? operands.slice(0, -1) : []];
6316
6352
  }
6317
- const KIND_LABELS = {
6318
- path: "path",
6319
- branch: "branch",
6320
- "remote-write": "remote target",
6321
- credential: "credential or global setting",
6322
- prune: "shared resource"
6323
- };
6324
- function formatContainmentReason(findings, scope) {
6325
- const targets = findings.slice(0, 5).map((finding) => `${KIND_LABELS[finding.kind]} ${finding.target}`).join(", ");
6326
- const more = findings.length > 5 ? ` and ${findings.length - 5} more` : "";
6327
- const [branch] = scope.branches;
6328
- return `This action writes outside the task scope: ${targets}${more}. The task owns ${`the worktree ${scope.worktrees.join(", ")}${branch === void 0 ? "" : ` and the branch ${branch}`}`}. Do the work inside the task's own worktree and branch, or ask the user to extend the task scope to this target.`;
6353
+ const KEYGEN_READ_OPTIONS = /* @__PURE__ */ new Set([
6354
+ "-l",
6355
+ "-y",
6356
+ "-F",
6357
+ "-B",
6358
+ "-R"
6359
+ ]);
6360
+ function collectKeygenFiles(args) {
6361
+ if (args.some((word) => KEYGEN_READ_OPTIONS.has(word))) return [];
6362
+ const index = args.indexOf("-f");
6363
+ const file = index === -1 ? void 0 : args[index + 1];
6364
+ return file === void 0 ? [] : [file];
6329
6365
  }
6330
- const PRESETS = {
6331
- jev: {
6332
- protocol: "system-one",
6333
- baseURL: "https://api.typesafe.ai",
6334
- model: "jev-1.13.0",
6335
- apiKeyEnv: "TYPESAFE_API_KEY",
6336
- reasoning: false,
6337
- maxTokens: 3e3,
6338
- timeoutMs: 5e3
6339
- },
6340
- spark: {
6341
- baseURL: "https://api.meta.ai",
6342
- model: "muse-spark-1.3-contributor",
6343
- apiKeyEnv: "META_API_KEY",
6344
- reasoning: true,
6345
- maxTokens: 3e3,
6346
- timeoutMs: 45e3
6347
- },
6348
- claude: {
6349
- baseURL: "https://api.anthropic.com",
6350
- model: "claude-haiku-4-5-20251001",
6351
- apiKeyEnv: "ANTHROPIC_API_KEY",
6352
- reasoning: true,
6353
- maxTokens: 3e3,
6354
- timeoutMs: 45e3
6355
- },
6356
- glm: {
6357
- baseURL: "https://api.z.ai/api/anthropic",
6358
- model: "glm-5.3-flash",
6359
- apiKeyEnv: "ZAI_API_KEY",
6360
- reasoning: true,
6361
- maxTokens: 3e3,
6362
- timeoutMs: 6e4
6366
+ const SSH_VALUE_OPTIONS = /* @__PURE__ */ new Set([
6367
+ "-b",
6368
+ "-c",
6369
+ "-D",
6370
+ "-E",
6371
+ "-F",
6372
+ "-i",
6373
+ "-J",
6374
+ "-l",
6375
+ "-L",
6376
+ "-m"
6377
+ ]);
6378
+ const SSH_MORE_VALUE_OPTIONS = /* @__PURE__ */ new Set([
6379
+ "-o",
6380
+ "-p",
6381
+ "-P",
6382
+ "-Q",
6383
+ "-R",
6384
+ "-S",
6385
+ "-W",
6386
+ "-w"
6387
+ ]);
6388
+ function collectSSHOperands(args) {
6389
+ const operands = [];
6390
+ for (let index = 0; index < args.length; index += 1) {
6391
+ const word = args[index] ?? "";
6392
+ if (operands.length > 0 || !word.startsWith("-")) operands.push(word);
6393
+ else if (SSH_VALUE_OPTIONS.has(word) || SSH_MORE_VALUE_OPTIONS.has(word)) index += 1;
6363
6394
  }
6364
- };
6365
- const MESSAGES_DEFAULTS = {
6366
- protocol: "messages",
6367
- baseURL: "https://api.anthropic.com",
6368
- reasoning: true,
6369
- maxTokens: 3e3,
6370
- timeoutMs: 45e3
6371
- };
6372
- function findPreset(id) {
6373
- return Object.hasOwn(PRESETS, id) ? PRESETS[id] : void 0;
6395
+ return operands;
6374
6396
  }
6375
- //#endregion
6376
- //#region src/config/read-api-key-from-command.ts
6377
- function readApiKeyFromCommand(command, options = {}) {
6378
- const remainingMs = options.deadlineAt === void 0 ? 5e3 : options.deadlineAt - Date.now();
6379
- if (remainingMs <= 0 || options.signal?.aborted === true) return Promise.resolve(null);
6380
- return new Promise((resolve) => {
6381
- const grouped = process.platform !== "win32";
6382
- const child = spawn("/bin/sh", ["-c", command], {
6383
- detached: grouped,
6384
- stdio: [
6385
- "ignore",
6386
- "pipe",
6387
- "ignore"
6388
- ]
6389
- });
6390
- let output = "";
6391
- let settled = false;
6392
- let stopped = false;
6393
- const setResult = (value) => {
6394
- if (!settled) {
6395
- settled = true;
6396
- clearTimeout(timer);
6397
- options.signal?.removeEventListener("abort", stopChild);
6398
- resolve(value);
6399
- }
6400
- };
6401
- const stopChild = () => {
6402
- if (stopped) return;
6403
- stopped = true;
6404
- try {
6405
- if (grouped && child.pid !== void 0) process.kill(-child.pid, "SIGKILL");
6406
- else child.kill("SIGKILL");
6407
- } catch {
6408
- child.kill("SIGKILL");
6409
- }
6410
- };
6411
- const timer = setTimeout(stopChild, Math.min(5e3, remainingMs));
6412
- options.signal?.addEventListener("abort", stopChild, { once: true });
6413
- child.stdout.setEncoding("utf8");
6414
- child.stdout.on("data", (chunk) => {
6415
- if (stopped) return;
6416
- output += chunk;
6417
- if (output.length > 65536) stopChild();
6418
- });
6419
- child.on("error", () => {
6420
- setResult(null);
6421
- });
6422
- child.on("close", (code) => {
6423
- const key = !stopped && code === 0 && output.trim() !== "" ? output.trim() : null;
6424
- setResult(key);
6425
- });
6397
+ const SSH_READS = /* @__PURE__ */ new Set([
6398
+ "cat",
6399
+ "df",
6400
+ "du",
6401
+ "free",
6402
+ "head",
6403
+ "id",
6404
+ "ls",
6405
+ "ps",
6406
+ "pwd",
6407
+ "stat",
6408
+ "tail",
6409
+ "uname",
6410
+ "uptime",
6411
+ "whoami"
6412
+ ]);
6413
+ function collectSSHFindings(args) {
6414
+ const [host, ...remote] = collectSSHOperands(args);
6415
+ if (host === void 0) return [];
6416
+ const command = remote.join(" ");
6417
+ const [program] = command.trim().split(/\s+/u);
6418
+ return SSH_READS.has(program ?? "") && !/[;&|<>`\n\r]|\$\(/u.test(command) ? [] : [{
6419
+ kind: "remote-write",
6420
+ target: "ssh"
6421
+ }];
6422
+ }
6423
+ const LOCAL_HOSTS = /* @__PURE__ */ new Set([
6424
+ "localhost",
6425
+ "127.0.0.1",
6426
+ "[::1]",
6427
+ "0.0.0.0"
6428
+ ]);
6429
+ function collectCurlFindings(args) {
6430
+ const joined = args.join(" ");
6431
+ const isUpload = args.some((word) => /^-[a-zA-Z]*[dFT]$|^--(?:data\S*|form|upload-file|json)$/u.test(word));
6432
+ const isWriteMethod = /(?:-X\s*|--request\s+)(?:POST|PUT|PATCH|DELETE)/u.test(joined);
6433
+ if (!isUpload && !isWriteMethod) return [];
6434
+ const hosts = args.filter((word) => /^(?:https?:\/\/|localhost\b|127\.)/u.test(word)).map((word) => /^(?:https?:\/\/)?(?<host>\[[^\]]+\]|[^/:?#\s]+)/u.exec(word)?.groups?.["host"]?.toLowerCase() ?? "");
6435
+ return hosts.length > 0 && hosts.every((host) => LOCAL_HOSTS.has(host)) ? [] : [{
6436
+ kind: "remote-write",
6437
+ target: "curl upload"
6438
+ }];
6439
+ }
6440
+ const GIT_DIRECTORY_WRITES = /* @__PURE__ */ new Set([
6441
+ "clean",
6442
+ "reset",
6443
+ "checkout",
6444
+ "switch",
6445
+ "merge",
6446
+ "commit",
6447
+ "rm",
6448
+ "stash",
6449
+ "push"
6450
+ ]);
6451
+ function collectGitFindings(args, directory, cwd, scope) {
6452
+ let gitDirectory = directory;
6453
+ let index = 0;
6454
+ while (index < args.length && args[index]?.startsWith("-") === true) {
6455
+ const option = args[index];
6456
+ if (option === "-C") gitDirectory = resolvePath(gitDirectory, args[index + 1] ?? "", scope.home);
6457
+ index += option === "-C" || option === "-c" ? 2 : 1;
6458
+ }
6459
+ const subcommand = args[index] ?? "";
6460
+ const rest = args.slice(index + 1);
6461
+ const operands = rest.filter((word) => !word.startsWith("-"));
6462
+ const findings = [];
6463
+ if (gitDirectory !== null && GIT_DIRECTORY_WRITES.has(subcommand) && !isInScope(gitDirectory, scope)) findings.push({
6464
+ kind: "path",
6465
+ target: gitDirectory
6466
+ });
6467
+ if (subcommand === "worktree" && operands[0] === "remove" && operands[1] !== void 0) findings.push(...collectTargetFindings(gitDirectory, operands[1], scope));
6468
+ 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({
6469
+ kind: "branch",
6470
+ target: branch
6471
+ });
6472
+ const isConfigRead = rest.some((word) => /^--(?:get|get-all|get-regexp|list)$|^-l$/u.test(word));
6473
+ if (subcommand === "config" && rest.includes("--global") && !isConfigRead) findings.push({
6474
+ kind: "credential",
6475
+ target: `git config ${operands[0] ?? ""}`
6426
6476
  });
6477
+ if (subcommand === "push") {
6478
+ const currentBranch = gitDirectory === cwd ? scope.currentBranch : null;
6479
+ findings.push(...collectPushFindings(rest, operands, currentBranch, scope));
6480
+ }
6481
+ return findings;
6427
6482
  }
6428
- //#endregion
6429
- //#region src/config/config.ts
6430
- const SCOPE_SOURCE_KINDS = [
6431
- "cwd",
6432
- "session",
6433
- "globs",
6434
- "atc"
6435
- ];
6436
- const DEFAULT_PROVIDER = findPreset("jev");
6437
- if (DEFAULT_PROVIDER === void 0) throw new Error("the jev preset is missing");
6438
- const DEFAULT_DENIAL_BUDGET = {
6439
- consecutive: 3,
6440
- perSession: 20
6441
- };
6442
- const DEFAULT_CONFIG = {
6443
- provider: DEFAULT_PROVIDER,
6444
- onFailure: "defer",
6445
- minConfidence: .8,
6446
- denialBudget: DEFAULT_DENIAL_BUDGET
6447
- };
6448
- function resolveConfigPath() {
6449
- const xdg = process.env["XDG_CONFIG_HOME"];
6450
- const base = xdg !== void 0 && xdg !== "" ? xdg : join(homedir(), ".config");
6451
- return join(base, "auto-mode", "config.json");
6483
+ function collectPushFindings(rest, operands, currentBranch, scope) {
6484
+ const [remote, ...refspecs] = operands;
6485
+ const findings = [];
6486
+ if (rest.includes("--all") || rest.includes("--mirror")) findings.push({
6487
+ kind: "branch",
6488
+ target: "--all"
6489
+ });
6490
+ if (remote !== void 0 && !isUnresolved(remote) && !isOwnedRemote(remote, scope)) findings.push({
6491
+ kind: "remote-write",
6492
+ target: remote
6493
+ });
6494
+ const destinations = refspecs.length === 0 && !rest.includes("--tags") ? ["HEAD"] : refspecs;
6495
+ for (const refspec of destinations) {
6496
+ const named = (refspec.split(":").at(-1) ?? refspec).replace(/^\+/u, "").replace(/^refs\/heads\//u, "");
6497
+ const branch = named === "HEAD" ? currentBranch : named;
6498
+ if (branch !== null && !isUnresolved(branch) && !branch.startsWith("refs/tags/") && !scope.branches.includes(branch)) findings.push({
6499
+ kind: "branch",
6500
+ target: branch
6501
+ });
6502
+ }
6503
+ return findings;
6504
+ }
6505
+ function isUnresolved(word) {
6506
+ return /[$`]/u.test(word);
6507
+ }
6508
+ function isOwnedRemote(remote, scope) {
6509
+ if (scope.remotes.some((entry) => entry.name === remote)) return true;
6510
+ const slug = toRepositorySlug(remote);
6511
+ return slug !== null && scope.remotes.some((entry) => toRepositorySlug(entry.url) === slug);
6512
+ }
6513
+ const GH_PR_WRITES = /* @__PURE__ */ new Set([
6514
+ "comment",
6515
+ "merge",
6516
+ "edit",
6517
+ "close",
6518
+ "review",
6519
+ "ready",
6520
+ "reopen"
6521
+ ]);
6522
+ const GH_REMOTE_WRITES = /* @__PURE__ */ new Set([
6523
+ "gist",
6524
+ "release",
6525
+ "repo",
6526
+ "ruleset",
6527
+ "label",
6528
+ "workflow"
6529
+ ]);
6530
+ const GH_CREDENTIAL_WRITES = /* @__PURE__ */ new Set([
6531
+ "secret",
6532
+ "variable",
6533
+ "ssh-key",
6534
+ "gpg-key"
6535
+ ]);
6536
+ const GH_READS = /* @__PURE__ */ new Set([
6537
+ "list",
6538
+ "view",
6539
+ "clone",
6540
+ "status",
6541
+ "checks",
6542
+ "diff"
6543
+ ]);
6544
+ function collectGhFindings(args, scope) {
6545
+ const group = args[0] ?? "";
6546
+ const verb = args[1] ?? "";
6547
+ if (group === "pr" && GH_PR_WRITES.has(verb)) {
6548
+ const number = Number(args.slice(2).find((word) => /^\d+$/u.test(word)));
6549
+ const repoIndex = args.findIndex((word) => word === "--repo" || word === "-R");
6550
+ const repo = repoIndex === -1 ? void 0 : args[repoIndex + 1];
6551
+ return verb !== "merge" && isOwnedPullRequest(number, repo, scope) ? [] : [{
6552
+ kind: "remote-write",
6553
+ target: `gh pr ${verb}`
6554
+ }];
6555
+ }
6556
+ if (group === "issue" && verb !== "" && !GH_READS.has(verb)) return [{
6557
+ kind: "remote-write",
6558
+ target: `gh issue ${verb}`
6559
+ }];
6560
+ if (group === "api") return collectGhAPIFindings(args.slice(1), scope);
6561
+ if (group === "auth" && [
6562
+ "refresh",
6563
+ "login",
6564
+ "logout"
6565
+ ].includes(verb)) return [{
6566
+ kind: "credential",
6567
+ target: `gh auth ${verb}`
6568
+ }];
6569
+ if (GH_CREDENTIAL_WRITES.has(group) && !GH_READS.has(verb)) return [{
6570
+ kind: "credential",
6571
+ target: `gh ${group} ${verb}`
6572
+ }];
6573
+ if (GH_REMOTE_WRITES.has(group) && !GH_READS.has(verb)) return [{
6574
+ kind: "remote-write",
6575
+ target: `gh ${group} ${verb}`
6576
+ }];
6577
+ return [];
6578
+ }
6579
+ function isOwnedPullRequest(number, repo, scope) {
6580
+ const wanted = repo?.toLowerCase().replace(/\.git$/u, "");
6581
+ const slugs = new Set(scope.remotes.map((remote) => toRepositorySlug(remote.url)));
6582
+ 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}`)));
6583
+ }
6584
+ function collectGhAPIFindings(args, scope) {
6585
+ const endpoint = args.find((word) => !word.startsWith("-")) ?? "";
6586
+ if (endpoint === "graphql") return args.some((word) => /\bmutation\b/u.test(word)) ? [{
6587
+ kind: "remote-write",
6588
+ target: "gh api graphql mutation"
6589
+ }] : [];
6590
+ const methodIndex = args.findIndex((word) => word === "-X" || word === "--method");
6591
+ const method = methodIndex === -1 ? null : (args[methodIndex + 1] ?? "").toUpperCase();
6592
+ const hasFields = args.some((word) => /^(?:-f|-F|--field|--raw-field|--input)$/u.test(word));
6593
+ if (method === "GET" || method === null && !hasFields) return [];
6594
+ const pull = /^\/?repos\/(?<repo>[^/]+\/[^/]+)\/(?:pulls|issues)\/(?<number>\d+)(?:\/|$)/u.exec(endpoint)?.groups;
6595
+ if (pull !== void 0 && isOwnedPullRequest(Number(pull["number"]), pull["repo"] ?? "", scope) && !endpoint.includes("/merge")) return [];
6596
+ return [{
6597
+ kind: /secret|collaborator|protection|key|permission/u.test(endpoint) ? "credential" : "remote-write",
6598
+ target: `gh api ${endpoint}`
6599
+ }];
6600
+ }
6601
+ const IAM_WRITE = /^(?:add|attach|change|create|deactivate|delete|detach|enable|put|remove|reset|set|tag|untag|update|upload)-/u;
6602
+ function findProgramFinding(name, args) {
6603
+ const joined = args.join(" ");
6604
+ if (name === "op" && /^(?:item|vault|document) (?:edit|create|delete)\b/u.test(joined)) return {
6605
+ kind: "credential",
6606
+ target: `op ${args[0] ?? ""} ${args[1] ?? ""}`
6607
+ };
6608
+ if (name === "aws" && args[0] === "iam" && IAM_WRITE.test(args[1] ?? "")) return {
6609
+ kind: "credential",
6610
+ target: `aws iam ${args[1] ?? ""}`
6611
+ };
6612
+ if (name === "docker" && /\b(?:volume|system|image|container) prune\b|\bvolume rm\b|\bdown\b.*(?:-v|--volumes)\b/u.test(joined)) return {
6613
+ kind: "prune",
6614
+ target: `docker ${joined}`
6615
+ };
6616
+ if (name === "npm" && args[0] === "publish") return {
6617
+ kind: "remote-write",
6618
+ target: "npm publish"
6619
+ };
6620
+ if (name === "kubectl" && /\b(?:apply|delete|replace|patch|scale)\b/u.test(joined)) return {
6621
+ kind: "remote-write",
6622
+ target: "kubectl write"
6623
+ };
6624
+ if (name === "terraform" && /\b(?:apply|destroy|import)\b/u.test(joined)) return {
6625
+ kind: "remote-write",
6626
+ target: "terraform write"
6627
+ };
6628
+ if (name === "psql" && /\b(?:UPDATE|DELETE|DROP|INSERT|TRUNCATE|ALTER)\b/iu.test(joined)) return {
6629
+ kind: "remote-write",
6630
+ target: "database write"
6631
+ };
6632
+ if (/^(?:bun|npm|pnpm|yarn)$/u.test(name) && /\bdeploy\b/u.test(joined) && /prod/u.test(joined)) return {
6633
+ kind: "remote-write",
6634
+ target: "production deploy"
6635
+ };
6636
+ return null;
6452
6637
  }
6453
- async function loadConfig(path = resolveConfigPath()) {
6454
- let raw;
6455
- try {
6456
- raw = await readFile(path, "utf8");
6457
- } catch (error) {
6458
- if (error instanceof Error && "code" in error && error.code === "ENOENT") return DEFAULT_CONFIG;
6459
- throw new Error("auto-mode configuration unreadable", { cause: error });
6460
- }
6461
- let json;
6462
- try {
6463
- json = JSON.parse(raw);
6464
- } catch {
6465
- throw new Error(`${path} is not valid JSON`);
6638
+ function collectRedirectTargets(text) {
6639
+ const targets = [];
6640
+ let quote = null;
6641
+ let index = 0;
6642
+ while (index < text.length) {
6643
+ const ch = text.charAt(index);
6644
+ if (quote !== null) {
6645
+ quote = ch === quote ? null : quote;
6646
+ index += 1;
6647
+ } else if (ch === "'" || ch === "\"") {
6648
+ quote = ch;
6649
+ index += 1;
6650
+ } else if (ch === ">" && text.charAt(index - 1) !== "<") {
6651
+ let next = index + 1;
6652
+ if (text.charAt(next) === ">" || text.charAt(next) === "|") next += 1;
6653
+ if (text.charAt(next) === "&") {
6654
+ index = next + 1;
6655
+ continue;
6656
+ }
6657
+ const [target, end] = readWord(text, next);
6658
+ if (target !== "") targets.push(target);
6659
+ index = end;
6660
+ } else index += 1;
6466
6661
  }
6467
- return buildConfig(json, path);
6468
- }
6469
- const text = string().min(1);
6470
- const positive = number().positive();
6471
- const denialBudgetSchema = strictObject({
6472
- consecutive: number().int().positive().optional(),
6473
- perSession: number().int().positive().optional()
6474
- });
6475
- const configFileSchema = strictObject({
6476
- classifiers: record(text, unknown()).optional(),
6477
- scopeSources: record(text, unknown()).optional(),
6478
- decision: strictObject({
6479
- classifier: text.optional(),
6480
- judge: text.nullable().optional(),
6481
- minConfidence: number().min(.5).max(1).optional(),
6482
- onFailure: _enum(["defer", "deny"]).optional(),
6483
- denialBudget: denialBudgetSchema.optional()
6484
- }).optional(),
6485
- policy: strictObject({
6486
- rulesPath: text.nullable().optional(),
6487
- frameworkPath: text.nullable().optional(),
6488
- claudeSettingsPath: text.nullable().optional()
6489
- }).optional()
6490
- });
6491
- function buildConfig(json, path) {
6492
- const parsed = configFileSchema.safeParse(json);
6493
- if (!parsed.success) throw new Error(`${path} is not a valid config: ${prettifyError(parsed.error)}`);
6494
- const file = parsed.data;
6495
- const warnings = [];
6496
- const classifiers = buildRegistry(file.classifiers ?? {}, (id, entry) => parseClassifierEntry(id, entry));
6497
- const scopeSources = buildRegistry(file.scopeSources ?? {}, (id, entry) => parseScopeSourceEntry(id, entry));
6498
- warnings.push(...classifiers.dropped.map((line) => `${path}: classifiers.${line}`), ...scopeSources.dropped.map((line) => `${path}: scopeSources.${line}`));
6499
- const decision = file.decision ?? {};
6500
- const policy = file.policy ?? {};
6501
- const judgeID = decision.judge ?? null;
6502
- return {
6503
- provider: resolveRole("classifier", decision.classifier ?? "jev", classifiers.entries, classifiers.droppedIDs, path),
6504
- judge: judgeID === null ? null : resolveRole("judge", judgeID, classifiers.entries, classifiers.droppedIDs, path),
6505
- scopeSources: scopeSources.entries,
6506
- classifierPath: expandHomePath(policy.frameworkPath ?? void 0),
6507
- rulesPath: expandHomePath(policy.rulesPath ?? void 0),
6508
- onFailure: decision.onFailure ?? DEFAULT_CONFIG.onFailure,
6509
- claudeSettingsPath: policy.claudeSettingsPath === null ? null : expandHomePath(policy.claudeSettingsPath),
6510
- minConfidence: decision.minConfidence ?? DEFAULT_CONFIG.minConfidence,
6511
- denialBudget: {
6512
- consecutive: decision.denialBudget?.consecutive ?? DEFAULT_DENIAL_BUDGET.consecutive,
6513
- perSession: decision.denialBudget?.perSession ?? DEFAULT_DENIAL_BUDGET.perSession
6514
- },
6515
- warnings
6516
- };
6662
+ return targets;
6517
6663
  }
6518
- function buildRegistry(raw, parseEntry) {
6519
- const entries = /* @__PURE__ */ new Map();
6520
- const dropped = [];
6521
- const droppedIDs = [];
6522
- for (const [id, entry] of Object.entries(raw)) {
6523
- const result = parseEntry(id, entry);
6524
- if (typeof result === "string") {
6525
- dropped.push(`${id} dropped: ${result}`);
6526
- droppedIDs.push(id);
6527
- } else entries.set(id, result);
6664
+ function readWord(text, start) {
6665
+ let index = start;
6666
+ while (/\s/u.test(text.charAt(index))) index += 1;
6667
+ let word = "";
6668
+ let quote = null;
6669
+ while (index < text.length) {
6670
+ const ch = text.charAt(index);
6671
+ if (quote !== null) {
6672
+ if (ch === quote) quote = null;
6673
+ else word += ch;
6674
+ } else if (ch === "'" || ch === "\"") quote = ch;
6675
+ else if (/[\s;&|<>()]/u.test(ch)) break;
6676
+ else word += ch;
6677
+ index += 1;
6528
6678
  }
6529
- return {
6530
- entries: Object.fromEntries(entries),
6531
- dropped,
6532
- droppedIDs
6533
- };
6679
+ return [word, index];
6534
6680
  }
6535
- const CLASSIFIER_KINDS = [...Object.keys(PRESETS), "messages"];
6536
- const classifierEntrySchema = strictObject({
6537
- kind: text.optional(),
6538
- baseURL: text.optional(),
6539
- model: text.optional(),
6540
- apiKeyEnv: text.optional(),
6541
- apiKeyCommand: text.optional(),
6542
- reasoning: boolean().optional(),
6543
- maxTokens: positive.optional(),
6544
- timeoutMs: positive.optional()
6545
- });
6546
- const CREDENTIAL_KEYS = /* @__PURE__ */ new Set([
6547
- "apiKey",
6548
- "key",
6549
- "token",
6550
- "secret"
6551
- ]);
6552
- function parseClassifierEntry(id, entry) {
6553
- if (typeof entry === "object" && entry !== null) {
6554
- const credential = Object.keys(entry).find((key) => CREDENTIAL_KEYS.has(key));
6555
- if (credential !== void 0) return `holds a literal ${credential}; name the key with apiKeyEnv or apiKeyCommand`;
6681
+ function collectInPlaceFiles(args) {
6682
+ const files = [];
6683
+ let skipNext = false;
6684
+ let sawScript = false;
6685
+ for (const word of args) if (skipNext) {
6686
+ skipNext = false;
6687
+ sawScript = true;
6688
+ } else if (/^-\w*e$/u.test(word)) skipNext = true;
6689
+ else if (!word.startsWith("-")) {
6690
+ if (sawScript) files.push(word);
6691
+ else sawScript = true;
6556
6692
  }
6557
- const parsed = classifierEntrySchema.safeParse(entry);
6558
- if (!parsed.success) return formatFirstIssue(parsed.error.issues);
6559
- const kind = parsed.data.kind ?? id;
6560
- if (!CLASSIFIER_KINDS.includes(kind)) return `unknown kind '${kind}'; known kinds are ${CLASSIFIER_KINDS.join(", ")}`;
6561
- const base = kind === "messages" ? MESSAGES_DEFAULTS : findPreset(kind);
6562
- const model = parsed.data.model ?? base?.model;
6563
- if (base === void 0 || model === void 0) return `kind '${kind}' needs a model`;
6564
- return {
6565
- protocol: base.protocol ?? "messages",
6566
- baseURL: parsed.data.baseURL ?? base.baseURL,
6567
- model,
6568
- apiKeyEnv: parsed.data.apiKeyEnv ?? base.apiKeyEnv,
6569
- apiKeyCommand: parsed.data.apiKeyCommand ?? base.apiKeyCommand,
6570
- reasoning: parsed.data.reasoning ?? base.reasoning,
6571
- maxTokens: parsed.data.maxTokens ?? base.maxTokens,
6572
- timeoutMs: parsed.data.timeoutMs ?? base.timeoutMs
6573
- };
6693
+ return files;
6574
6694
  }
6575
- const scopeSourceEntrySchema = strictObject({
6576
- kind: text.optional(),
6577
- paths: array(text).min(1).optional()
6578
- });
6579
- function parseScopeSourceEntry(id, entry) {
6580
- const parsed = scopeSourceEntrySchema.safeParse(entry);
6581
- if (!parsed.success) return formatFirstIssue(parsed.error.issues);
6582
- const kind = SCOPE_SOURCE_KINDS.find((known) => known === (parsed.data.kind ?? id));
6583
- if (kind === void 0) return `unknown kind '${parsed.data.kind ?? id}'; known kinds are ${SCOPE_SOURCE_KINDS.join(", ")}`;
6584
- if (kind === "globs") return parsed.data.paths === void 0 ? "kind 'globs' needs paths" : {
6585
- kind,
6586
- paths: parsed.data.paths
6587
- };
6588
- return parsed.data.paths === void 0 ? { kind } : `kind '${kind}' takes no paths`;
6695
+ function resolvePath(cwd, path, home) {
6696
+ const expanded = path.replace(/^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/u, home);
6697
+ if (/[$`]|^~/u.test(expanded) || expanded === "") return null;
6698
+ if (cwd === null && !isAbsolute(expanded)) return null;
6699
+ const absolute = isAbsolute(expanded) || cwd === null ? expanded : join(cwd, expanded);
6700
+ return normalize(absolute).replace(/(?<=.)\/$/u, "");
6589
6701
  }
6590
- function formatFirstIssue(issues) {
6591
- const [issue] = issues;
6592
- if (issue === void 0) return "invalid entry";
6593
- return issue.path.length === 0 ? issue.message : `${issue.path.map(String).join(".")}: ${issue.message}`;
6702
+ const SCRATCH_PATHS = [
6703
+ "/tmp",
6704
+ "/dev/null",
6705
+ "/dev/stdout",
6706
+ "/dev/stderr"
6707
+ ];
6708
+ function isInScope(path, scope) {
6709
+ if (SCRATCH_PATHS.some((entry) => isUnder(path, entry)) || scope.pathGlobs.some((glob) => matchesGlob(path, glob))) return true;
6710
+ return scope.worktrees.some((worktree) => {
6711
+ const isNested = isUnder(path, join(worktree, ".worktrees"));
6712
+ const isOtherOwned = scope.worktrees.some((other) => other !== worktree && isUnder(path, other));
6713
+ return isUnder(path, worktree) && (!isNested || isOtherOwned);
6714
+ });
6594
6715
  }
6595
- function resolveRole(role, id, entries, droppedIDs, path) {
6596
- const entry = Object.hasOwn(entries, id) ? entries[id] : void 0;
6597
- if (entry !== void 0) return entry;
6598
- if (droppedIDs.includes(id)) throw new Error(`${path}: decision.${role} names '${id}', whose entry was dropped`);
6599
- if (findPreset(id) === void 0) throw new Error(`${path}: decision.${role} names '${id}', which is neither a classifiers entry nor a built-in kind (${Object.keys(PRESETS).join(", ")})`);
6600
- return parseBuiltIn(id);
6716
+ function isUnder(path, root) {
6717
+ return path === root || path.startsWith(`${root}/`);
6601
6718
  }
6602
- function parseBuiltIn(id) {
6603
- const resolved = parseClassifierEntry(id, {});
6604
- if (typeof resolved === "string") throw new TypeError(`built-in kind ${id} does not resolve: ${resolved}`);
6605
- return resolved;
6719
+ //#endregion
6720
+ //#region src/containment/check-containment.ts
6721
+ const CONTAINMENT_RULE = "Outside Task Scope";
6722
+ async function checkContainment(request, sources) {
6723
+ let scope;
6724
+ try {
6725
+ scope = await loadTaskScope({
6726
+ sessionID: request.sessionID,
6727
+ cwd: request.cwd,
6728
+ stateDir: resolveStateDir()
6729
+ }, sources);
6730
+ } catch {
6731
+ return null;
6732
+ }
6733
+ const findings = collectScopeFindings({
6734
+ tool: request.toolName,
6735
+ cwd: request.cwd,
6736
+ input: request.toolInput
6737
+ }, scope);
6738
+ if (findings.length === 0) return null;
6739
+ return {
6740
+ rule: CONTAINMENT_RULE,
6741
+ reason: formatContainmentReason(findings, scope),
6742
+ findings
6743
+ };
6606
6744
  }
6607
- function expandHomePath(path) {
6608
- return path?.startsWith("~/") === true ? join(homedir(), path.slice(2)) : path;
6745
+ const KIND_LABELS = {
6746
+ path: "path",
6747
+ branch: "branch",
6748
+ "remote-write": "remote target",
6749
+ credential: "credential or global setting",
6750
+ prune: "shared resource"
6751
+ };
6752
+ function formatContainmentReason(findings, scope) {
6753
+ const targets = findings.slice(0, 5).map((finding) => `${KIND_LABELS[finding.kind]} ${finding.target}`).join(", ");
6754
+ const more = findings.length > 5 ? ` and ${findings.length - 5} more` : "";
6755
+ const branches = scope.branches.length === 0 ? "" : ` and ${formatList("branch", "branches", scope.branches)}`;
6756
+ return `This action writes outside the task scope: ${targets}${more}. The task owns ${`${formatList("worktree", "worktrees", scope.worktrees)}${branches}`}. Do the work inside the task's own worktree and branch, or ask the user to extend the task scope to this target.`;
6609
6757
  }
6610
- function resolveApiKey(provider, options = {}) {
6611
- const fromEnv = provider.apiKeyEnv === void 0 ? void 0 : process.env[provider.apiKeyEnv];
6612
- if (fromEnv !== void 0 && fromEnv !== "") return Promise.resolve(fromEnv);
6613
- if (provider.apiKeyCommand === void 0 || provider.apiKeyCommand === "") return Promise.resolve(null);
6614
- return readApiKeyFromCommand(provider.apiKeyCommand, options);
6758
+ function formatList(one, many, values) {
6759
+ return `the ${values.length === 1 ? one : many} ${values.join(", ")}`;
6615
6760
  }
6616
6761
  //#endregion
6617
6762
  //#region src/policy/resolve-shipped-policy-dir.ts
@@ -7743,7 +7888,7 @@ async function classifyAction(request, config, options = {}) {
7743
7888
  }
7744
7889
  const local = configured !== null && (configured.hard_deny.length > 0 || configured.soft_deny.length > 0) ? { kind: "escalate" } : classifyLocally(request);
7745
7890
  if (local.kind === "allow" && local.exception === READ_ONLY_EXCEPTION) return buildLocalAllow(local.exception);
7746
- const containment = await checkContainment(request);
7891
+ const containment = await checkContainment(request, config.scopeSources ?? DEFAULT_SCOPE_SOURCES);
7747
7892
  if (containment !== null) {
7748
7893
  const guidance = await tryReadDenialGuidance();
7749
7894
  return {
@@ -7850,4 +7995,4 @@ function renderVerdict(verdict) {
7850
7995
  })).exhaustive();
7851
7996
  }
7852
7997
  //#endregion
7853
- export { classifyWithModel as a, DEFAULT_CONFIG as c, resolveApiKey as d, resolveConfigPath as f, string as g, strictObject as h, classifyLocally as i, DEFAULT_DENIAL_BUDGET as l, number as m, parseActionRequest as n, parseModelVerdict as o, PRESETS as p, classifyAction as r, loadPolicy as s, renderVerdict as t, loadConfig as u };
7998
+ export { resolveConfigPath as C, string as D, strictObject as E, resolveApiKey as S, number as T, loadRepositoryContext as _, classifyWithModel as a, DEFAULT_SCOPE_SOURCES as b, splitShellCommand as c, resolveSessionScopePath as d, loadSessionScope as f, toRepositorySlug as g, findCheckout as h, classifyLocally as i, splitCommandWords as l, loadCheckoutRemotes as m, parseActionRequest as n, parseModelVerdict as o, EMPTY_SESSION_SCOPE as p, classifyAction as r, loadPolicy as s, renderVerdict as t, resolveStateDir as u, DEFAULT_CONFIG as v, PRESETS as w, loadConfig as x, DEFAULT_DENIAL_BUDGET as y };