auto-mode 0.19.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as readHostEnvironment, T as resolveXDGDir, a as classifyAction, b as loadConfig, i as recordScope, m as writeActionDiagnostic, n as parseScopeRecordRequest, p as loadPolicy, r as parseActionRequest, t as renderVerdict, v as findGitEntry } from "./render-verdict-DBcgB5CC.js";
2
+ import { C as readHostEnvironment, T as resolveXDGDir, a as classifyAction, b as loadConfig, i as recordScope, m as writeActionDiagnostic, n as parseScopeRecordRequest, p as loadPolicy, r as parseActionRequest, t as renderVerdict, v as findGitEntry } from "./render-verdict-0NxrIuIc.js";
3
3
  import { chmod, mkdir, open, realpath, stat } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
package/dist/index.d.ts CHANGED
@@ -37,8 +37,18 @@ interface MCPServerFact {
37
37
  readonly transport: 'stdio' | 'http' | 'sse' | 'ws';
38
38
  readonly host: string | null;
39
39
  }
40
+ interface NamedTarget {
41
+ readonly kind: string;
42
+ readonly target: string;
43
+ readonly namedAs: string;
44
+ }
45
+ interface ContainmentEvidence {
46
+ readonly reason: string;
47
+ readonly targets: readonly NamedTarget[];
48
+ }
40
49
  interface JudgeEvidence extends DeniedRule {
41
50
  readonly repositoryContext: RepositoryContext | null;
51
+ readonly containment?: ContainmentEvidence | undefined;
42
52
  }
43
53
  //#endregion
44
54
  //#region src/config/types.d.ts
@@ -4629,6 +4639,7 @@ interface ActionOutcome {
4629
4639
  readonly unavailable?: boolean;
4630
4640
  readonly diagnostics?: DecisionDiagnostics;
4631
4641
  readonly judge?: JudgeDiagnostics;
4642
+ readonly grant?: JudgeDiagnostics;
4632
4643
  readonly denials?: {
4633
4644
  readonly consecutive: number;
4634
4645
  readonly session: number;
@@ -4648,9 +4659,16 @@ export declare function classifyAction(request: ActionRequest, config: Config$1,
4648
4659
  export declare function loadClaudeRules(path: string | null | undefined, host: Readonly<HostEnvironment>): Promise<ClaudeRules>;
4649
4660
  //#endregion
4650
4661
  //#region src/containment/collect-scope-findings.d.ts
4662
+ type ScopeGrant = {
4663
+ readonly kind: 'path' | 'branch';
4664
+ } | {
4665
+ readonly kind: 'pull-request';
4666
+ readonly number: number;
4667
+ };
4651
4668
  interface ScopeFinding {
4652
- readonly kind: 'path' | 'branch' | 'remote-write' | 'credential' | 'prune';
4669
+ readonly kind: 'path' | 'branch' | 'remote-write' | 'credential' | 'prune' | 'discard';
4653
4670
  readonly target: string;
4671
+ readonly grant?: ScopeGrant;
4654
4672
  }
4655
4673
  //#endregion
4656
4674
  //#region src/containment/check-containment.d.ts
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { S as resolveConfigPath, _ as tryClassifyEdit, a as classifyAction, b as loadConfig, c as classifyWithModel, d as DecisionRequestError, f as buildDecisionRequest, g as loadClaudeSettings, h as checkContainment, i as recordScope, l as parseModelVerdict, n as parseScopeRecordRequest, o as buildTaskScope, p as loadPolicy, r as parseActionRequest, s as classifyLocally, t as renderVerdict, u as sendDecision, w as PRESETS, x as resolveApiKey, y as DEFAULT_CONFIG } from "./render-verdict-DBcgB5CC.js";
1
+ import { S as resolveConfigPath, _ as tryClassifyEdit, a as classifyAction, b as loadConfig, c as classifyWithModel, d as DecisionRequestError, f as buildDecisionRequest, g as loadClaudeSettings, h as checkContainment, i as recordScope, l as parseModelVerdict, n as parseScopeRecordRequest, o as buildTaskScope, p as loadPolicy, r as parseActionRequest, s as classifyLocally, t as renderVerdict, u as sendDecision, w as PRESETS, x as resolveApiKey, y as DEFAULT_CONFIG } from "./render-verdict-0NxrIuIc.js";
2
2
  //#region src/config/load-claude-rules.ts
3
3
  async function loadClaudeRules(path, host) {
4
4
  return (await loadClaudeSettings(path, host)).rules;
@@ -6032,6 +6032,11 @@ async function tryClassifyEdit(request, scope, host, reader = {}) {
6032
6032
  }
6033
6033
  }
6034
6034
  //#endregion
6035
+ //#region mods/auto-mode/contract/find-current-direct-user-message.ts
6036
+ function findCurrentDirectUserMessage(message) {
6037
+ return message === null || message === void 0 || message.freshness === "stale" ? null : message.text;
6038
+ }
6039
+ //#endregion
6035
6040
  //#region src/budget/build-retry-key.ts
6036
6041
  const DESCRIPTIVE_FIELDS = { Bash: [
6037
6042
  "description",
@@ -6556,13 +6561,14 @@ const CREDENTIAL_PATHS = [
6556
6561
  ".config/gh"
6557
6562
  ];
6558
6563
  function collectPathFindings(path, scope, scratchPaths) {
6559
- if (CREDENTIAL_PATHS.some((entry) => isUnder(path, join(scope.home, entry)))) return [{
6564
+ if (CREDENTIAL_PATHS.some((entry) => isUnder$1(path, join(scope.home, entry)))) return [{
6560
6565
  kind: "credential",
6561
6566
  target: path
6562
6567
  }];
6563
6568
  return isInScope(path, scope, scratchPaths) ? [] : [{
6564
6569
  kind: "path",
6565
- target: path
6570
+ target: path,
6571
+ grant: { kind: "path" }
6566
6572
  }];
6567
6573
  }
6568
6574
  function collectToolFindings(action, scope) {
@@ -6572,7 +6578,15 @@ function collectToolFindings(action, scope) {
6572
6578
  const owner = action.input["owner"];
6573
6579
  const name = action.input["repo"];
6574
6580
  const repo = typeof owner === "string" && typeof name === "string" ? `${owner}/${name}` : void 0;
6575
- return !verb.includes("merge") && typeof number === "number" && isOwnedPullRequest(number, repo, scope) ? [] : [{
6581
+ if (!verb.includes("merge") && typeof number === "number" && isOwnedPullRequest(number, repo, scope)) return [];
6582
+ return typeof number === "number" ? [{
6583
+ kind: "remote-write",
6584
+ target: action.tool,
6585
+ grant: {
6586
+ kind: "pull-request",
6587
+ number
6588
+ }
6589
+ }] : [{
6576
6590
  kind: "remote-write",
6577
6591
  target: action.tool
6578
6592
  }];
@@ -6593,13 +6607,120 @@ const DESTINATION_WRITERS = /* @__PURE__ */ new Set([
6593
6607
  ]);
6594
6608
  function collectCommandFindings(command, cwd, scope, scratchPaths) {
6595
6609
  const findings = [];
6596
- for (const entry of collectShellCommands(command, cwd, scope.home)) {
6610
+ const entries = collectShellCommands(command, cwd, scope.home);
6611
+ const piped = collectPipedSegments(command);
6612
+ const segments = splitShellCommand(command).segments;
6613
+ for (const [index, entry] of entries.entries()) {
6597
6614
  const [name, ...args] = entry.words;
6615
+ const fixed = findFixedXargsCommand(entry.words, segments[index]?.text ?? "");
6616
+ if (fixed !== null) findings.push(...collectProgramFindings(fixed.name, fixed.args, entry.directory, cwd, scope, scratchPaths));
6617
+ else if (isBranchSweep(entries.slice(0, index + 1), piped.slice(0, index + 1))) findings.push({
6618
+ kind: "discard",
6619
+ target: "git branch delete by exclusion"
6620
+ });
6598
6621
  for (const target of entry.writeTargets) findings.push(...collectTargetFindings(entry.directory, target, scope, scratchPaths));
6599
6622
  if (name !== void 0 && name !== "cd") findings.push(...collectProgramFindings(name, args, entry.directory, cwd, scope, scratchPaths));
6600
6623
  }
6601
6624
  return findings;
6602
6625
  }
6626
+ function collectPipedSegments(command) {
6627
+ const piped = [];
6628
+ let separator = null;
6629
+ let isInSegment = false;
6630
+ for (const token of splitShellTokens(command).tokens) if (token.kind === "separator") {
6631
+ separator = token.operator;
6632
+ isInSegment = false;
6633
+ } else if (!isInSegment) {
6634
+ piped.push(separator === "|");
6635
+ isInSegment = true;
6636
+ }
6637
+ return piped;
6638
+ }
6639
+ const XARGS_FLAG = /^(?:-[0oprtx]+|--(?:null|no-run-if-empty|verbose|interactive|exit|open-tty))$/u;
6640
+ const XARGS_VALUE_OPTION = /^-[EPads]$/u;
6641
+ const XARGS_ATTACHED_VALUE = /^(?:-[EPads].|--(?:eof|max-procs|delimiter|arg-file|max-chars|process-slot-var)=)/u;
6642
+ const XARGS_REPLACE = /^(?:-I(?<attached>.+)|-i(?<short>.*)|--replace(?:=(?<long>.*))?)$/u;
6643
+ const PLAIN_WORD = /^(?:[\w./:@%+=-]|\{\})+$/u;
6644
+ function findFixedXargsCommand(words, text) {
6645
+ if (words[0] !== "xargs" || !splitShellTokens(text).tokens.every((token) => token.kind !== "word" || token.raw === token.value && PLAIN_WORD.test(token.value))) return null;
6646
+ let token = null;
6647
+ let start = 1;
6648
+ while (words[start]?.startsWith("-") === true) {
6649
+ const word = words[start] ?? "";
6650
+ const replace = XARGS_REPLACE.exec(word)?.groups;
6651
+ if (word === "--") {
6652
+ start += 1;
6653
+ break;
6654
+ }
6655
+ if (word === "-I") {
6656
+ token = words[start + 1] ?? null;
6657
+ start += 2;
6658
+ } else if (replace !== void 0) {
6659
+ const short = replace["short"];
6660
+ token = replace["attached"] ?? replace["long"] ?? (short === void 0 || short === "" ? "{}" : short);
6661
+ start += 1;
6662
+ } else if (word.startsWith("-J")) {
6663
+ token = null;
6664
+ start += word === "-J" ? 2 : 1;
6665
+ } else if (XARGS_VALUE_OPTION.test(word)) start += 2;
6666
+ else if (XARGS_FLAG.test(word) || XARGS_ATTACHED_VALUE.test(word)) start += 1;
6667
+ else return null;
6668
+ }
6669
+ const [name, ...args] = words.slice(start);
6670
+ if (token === null || token === "" || name === void 0 || [name, ...args].some((word) => word.includes(token))) return null;
6671
+ return {
6672
+ name,
6673
+ args
6674
+ };
6675
+ }
6676
+ const XARGS_VALUE_OPTIONS = /* @__PURE__ */ new Set([
6677
+ "-I",
6678
+ "-J",
6679
+ "-L",
6680
+ "-n",
6681
+ "-P",
6682
+ "-s",
6683
+ "-d",
6684
+ "-E"
6685
+ ]);
6686
+ function isBranchSweep(entries, piped) {
6687
+ const deleter = entries.at(-1)?.words ?? [];
6688
+ if (deleter[0] !== "xargs" || piped.at(-1) !== true) return false;
6689
+ let start = 1;
6690
+ while (deleter[start]?.startsWith("-") === true) {
6691
+ if (/^(?:-a|--arg-file)/u.test(deleter[start] ?? "")) return false;
6692
+ start += XARGS_VALUE_OPTIONS.has(deleter[start] ?? "") ? 2 : 1;
6693
+ }
6694
+ const [program, subcommand, ...rest] = deleter.slice(start);
6695
+ if (program !== "git" || subcommand !== "branch" || !rest.some((word) => /^-(?:d|D|-delete)$/u.test(word))) return false;
6696
+ let first = entries.length - 2;
6697
+ while (first > 0 && piped[first] === true) first -= 1;
6698
+ const [listing, ...filters] = entries.slice(first, -1).map((entry) => entry.words);
6699
+ return listing !== void 0 && isWholeBranchListing(listing) && filters.every((filter) => isExcludingFilter(filter));
6700
+ }
6701
+ function isWholeBranchListing(words) {
6702
+ const [program, subcommand, ...rest] = words;
6703
+ if (program !== "git") return false;
6704
+ if (subcommand === "branch") return rest.every((word) => /^(?:-a|-r|-v|-vv|--all|--remotes|--no-color|--format=.*)$/u.test(word));
6705
+ if (subcommand !== "for-each-ref") return false;
6706
+ return rest.filter((_word, index) => !/^--(?:format|sort)$/u.test(rest[index - 1] ?? "")).every((word) => /^--(?:format|sort)(?:=.*)?$/u.test(word) || /^refs\/heads\/?$/u.test(word));
6707
+ }
6708
+ const NAME_EDITORS = /* @__PURE__ */ new Set([
6709
+ "tr",
6710
+ "cut",
6711
+ "sort"
6712
+ ]);
6713
+ function isExcludingFilter(words) {
6714
+ const [program, ...args] = words;
6715
+ if (program === "grep" || program === "egrep") return args.some((word) => /^-[a-zA-Z]*v[a-zA-Z]*$|^--invert-match$/u.test(word));
6716
+ if (program === "sed") return isSubstitutionOnly(args);
6717
+ if (program === "uniq") return args.every((word) => !/^-[a-zA-Z]*[dDu]/u.test(word));
6718
+ return program !== void 0 && NAME_EDITORS.has(program);
6719
+ }
6720
+ function isSubstitutionOnly(args) {
6721
+ const scripts = args.filter((word) => word !== "-e" && word !== "-E" && word !== "-r");
6722
+ return scripts.length > 0 && scripts.every((script) => /^s(?<delimiter>[^\w\s;])(?:(?!\k<delimiter>).)*\k<delimiter>(?:(?!\k<delimiter>).)*\k<delimiter>g?$/u.test(script));
6723
+ }
6603
6724
  function collectProgramFindings(name, args, directory, cwd, scope, scratchPaths) {
6604
6725
  const resolve = (path) => collectTargetFindings(directory, path, scope, scratchPaths);
6605
6726
  const operands = args.filter((word) => !word.startsWith("-"));
@@ -6754,7 +6875,13 @@ function collectGitFindings(args, directory, cwd, scope, scratchPaths) {
6754
6875
  if (subcommand === "worktree" && operands[0] === "remove" && operands[1] !== void 0) findings.push(...collectTargetFindings(gitDirectory, operands[1], scope, scratchPaths));
6755
6876
  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({
6756
6877
  kind: "branch",
6757
- target: branch
6878
+ target: branch,
6879
+ grant: { kind: "branch" }
6880
+ });
6881
+ const [resetTarget] = operands;
6882
+ if (subcommand === "reset" && rest.includes("--hard") && resetTarget !== void 0 && resetTarget !== "HEAD" && resetTarget !== "@" && !isUnresolvedWord(resetTarget)) findings.push({
6883
+ kind: "discard",
6884
+ target: `git reset --hard ${resetTarget}`
6758
6885
  });
6759
6886
  const isConfigRead = rest.some((word) => /^--(?:get|get-all|get-regexp|list)$|^-l$/u.test(word));
6760
6887
  if (subcommand === "config" && rest.includes("--global") && !isConfigRead) findings.push({
@@ -6784,7 +6911,8 @@ function collectPushFindings(rest, operands, currentBranch, scope) {
6784
6911
  const branch = named === "HEAD" ? currentBranch : named;
6785
6912
  if (branch !== null && !isUnresolvedWord(branch) && !branch.startsWith("refs/tags/") && !scope.branches.includes(branch)) findings.push({
6786
6913
  kind: "branch",
6787
- target: branch
6914
+ target: branch,
6915
+ grant: { kind: "branch" }
6788
6916
  });
6789
6917
  }
6790
6918
  return findings;
@@ -6829,10 +6957,19 @@ function collectGhFindings(args, scope) {
6829
6957
  const group = args[0] ?? "";
6830
6958
  const verb = args[1] ?? "";
6831
6959
  if (group === "pr" && GH_PR_WRITES.has(verb)) {
6832
- const number = Number(args.slice(2).find((word) => /^\d+$/u.test(word)));
6833
- const repoIndex = args.findIndex((word) => word === "--repo" || word === "-R");
6834
- const repo = repoIndex === -1 ? void 0 : args[repoIndex + 1];
6835
- return verb !== "merge" && isOwnedPullRequest(number, repo, scope) ? [] : [{
6960
+ const pull = findPullRequestSelector(verb, args.slice(2));
6961
+ const number = pull?.number ?? NaN;
6962
+ const isOwned = verb !== "merge" && isOwnedPullRequest(number, pull?.repo, scope);
6963
+ const isAdmin = args.some((word) => /^--admin(?:=|$)/u.test(word));
6964
+ if (isOwned) return [];
6965
+ return Number.isInteger(number) && !isAdmin ? [{
6966
+ kind: "remote-write",
6967
+ target: `gh pr ${verb}`,
6968
+ grant: {
6969
+ kind: "pull-request",
6970
+ number
6971
+ }
6972
+ }] : [{
6836
6973
  kind: "remote-write",
6837
6974
  target: `gh pr ${verb}`
6838
6975
  }];
@@ -6860,6 +6997,79 @@ function collectGhFindings(args, scope) {
6860
6997
  }];
6861
6998
  return [];
6862
6999
  }
7000
+ const GH_PR_VALUE_OPTIONS = {
7001
+ comment: /* @__PURE__ */ new Set([
7002
+ "-b",
7003
+ "--body",
7004
+ "-F",
7005
+ "--body-file",
7006
+ "--attach"
7007
+ ]),
7008
+ merge: /* @__PURE__ */ new Set([
7009
+ "-A",
7010
+ "--author-email",
7011
+ "-b",
7012
+ "--body",
7013
+ "-F",
7014
+ "--body-file",
7015
+ "--match-head-commit",
7016
+ "-t",
7017
+ "--subject"
7018
+ ]),
7019
+ edit: /* @__PURE__ */ new Set([
7020
+ "--add-assignee",
7021
+ "--add-label",
7022
+ "--add-project",
7023
+ "--add-reviewer",
7024
+ "--attach",
7025
+ "-B",
7026
+ "--base",
7027
+ "-b",
7028
+ "--body",
7029
+ "-F",
7030
+ "--body-file",
7031
+ "-m",
7032
+ "--milestone",
7033
+ "--remove-assignee",
7034
+ "--remove-label",
7035
+ "--remove-project",
7036
+ "--remove-reviewer",
7037
+ "-t",
7038
+ "--title"
7039
+ ]),
7040
+ close: /* @__PURE__ */ new Set(["-c", "--comment"]),
7041
+ review: /* @__PURE__ */ new Set([
7042
+ "-b",
7043
+ "--body",
7044
+ "-F",
7045
+ "--body-file"
7046
+ ]),
7047
+ ready: /* @__PURE__ */ new Set(),
7048
+ reopen: /* @__PURE__ */ new Set(["-c", "--comment"])
7049
+ };
7050
+ const GH_REPO_OPTIONS = /* @__PURE__ */ new Set(["-R", "--repo"]);
7051
+ function findPullRequestSelector(verb, args) {
7052
+ const valueOptions = GH_PR_VALUE_OPTIONS[verb] ?? /* @__PURE__ */ new Set();
7053
+ let repo;
7054
+ let selector;
7055
+ for (let index = 0; index < args.length; index += 1) {
7056
+ const word = args[index] ?? "";
7057
+ if (GH_REPO_OPTIONS.has(word)) repo = args[index + 1];
7058
+ else if (word.startsWith("--repo=")) repo = word.slice(7);
7059
+ if (GH_REPO_OPTIONS.has(word) || valueOptions.has(word)) index += 1;
7060
+ else if (selector === void 0 && !word.startsWith("-")) selector = word;
7061
+ }
7062
+ if (selector === void 0) return null;
7063
+ const url = /^https?:\/\/(?<repo>[^/]+\/[^/]+\/[^/]+)\/pull\/(?<number>\d+)(?:[/?#]|$)/u.exec(selector)?.groups;
7064
+ if (url !== void 0) return {
7065
+ number: Number(url["number"]),
7066
+ repo: url["repo"]
7067
+ };
7068
+ return {
7069
+ number: /^#?\d+$/u.test(selector) ? Number(selector.replace("#", "")) : null,
7070
+ repo
7071
+ };
7072
+ }
6863
7073
  function isOwnedPullRequest(number, repo, scope) {
6864
7074
  const wanted = repo?.toLowerCase().replace(/\.git$/u, "");
6865
7075
  const slugs = new Set(scope.remotes.map((remote) => toRepositorySlug(remote.url)));
@@ -6934,14 +7144,14 @@ function collectInPlaceFiles(args) {
6934
7144
  return files;
6935
7145
  }
6936
7146
  function isInScope(path, scope, scratchPaths) {
6937
- if (scratchPaths.some((entry) => isUnder(path, entry)) || scope.pathGlobs.some((glob) => matchesGlob(path, glob))) return true;
7147
+ if (scratchPaths.some((entry) => isUnder$1(path, entry)) || scope.pathGlobs.some((glob) => matchesGlob(path, glob))) return true;
6938
7148
  return scope.worktrees.some((worktree) => {
6939
- const isNested = isUnder(path, join(worktree, ".worktrees"));
6940
- const isOtherOwned = scope.worktrees.some((other) => other !== worktree && isUnder(path, other));
6941
- return isUnder(path, worktree) && (!isNested || isOtherOwned);
7149
+ const isNested = isUnder$1(path, join(worktree, ".worktrees"));
7150
+ const isOtherOwned = scope.worktrees.some((other) => other !== worktree && isUnder$1(path, other));
7151
+ return isUnder$1(path, worktree) && (!isNested || isOtherOwned);
6942
7152
  });
6943
7153
  }
6944
- function isUnder(path, root) {
7154
+ function isUnder$1(path, root) {
6945
7155
  return path === root || path.startsWith(`${root}/`);
6946
7156
  }
6947
7157
  //#endregion
@@ -6965,27 +7175,103 @@ const KIND_LABELS = {
6965
7175
  branch: "branch",
6966
7176
  "remote-write": "remote target",
6967
7177
  credential: "credential or global setting",
6968
- prune: "shared resource"
7178
+ prune: "shared resource",
7179
+ discard: "discarded work"
6969
7180
  };
6970
7181
  function formatContainmentReason(findings, scope) {
6971
7182
  const targets = findings.slice(0, 5).map((finding) => `${KIND_LABELS[finding.kind]} ${finding.target}`).join(", ");
6972
7183
  const more = findings.length > 5 ? ` and ${findings.length - 5} more` : "";
6973
7184
  const branches = scope.branches.length === 0 ? "" : ` and ${formatList("branch", "branches", scope.branches)}`;
6974
- 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.`;
7185
+ return `This action writes outside the task scope: ${targets}${more}. The task owns ${`${formatList("worktree", "worktrees", scope.worktrees)}${branches}`}. ${formatAdvice(findings)}`;
7186
+ }
7187
+ const DISCARD_ADVICE = "A hard reset onto another commit, or a branch deletion that names no branch, discards commits and changes the task may not have made. Sync a branch with git merge --ff-only or git rebase, and delete branches by name, or ask the user to run this action.";
7188
+ const SCOPE_ADVICE = "Do the work inside the task's own worktree and branch, or ask the user to extend the task scope to this target.";
7189
+ function formatAdvice(findings) {
7190
+ const discards = findings.filter((finding) => finding.kind === "discard").length;
7191
+ if (discards === 0) return SCOPE_ADVICE;
7192
+ return discards === findings.length ? DISCARD_ADVICE : `${DISCARD_ADVICE} ${SCOPE_ADVICE}`;
6975
7193
  }
6976
7194
  function formatList(one, many, values) {
6977
7195
  return `the ${values.length === 1 ? one : many} ${values.join(", ")}`;
6978
7196
  }
6979
7197
  //#endregion
7198
+ //#region src/scope/protected-branch-names.ts
7199
+ const PROTECTED_BRANCH_NAMES = /* @__PURE__ */ new Set([
7200
+ "main",
7201
+ "master",
7202
+ "trunk",
7203
+ "develop"
7204
+ ]);
7205
+ //#endregion
7206
+ //#region src/containment/find-user-named-grant.ts
7207
+ function findUserNamedGrant(findings, message, context) {
7208
+ const words = splitMessageWords(message);
7209
+ const named = [];
7210
+ for (const finding of findings) {
7211
+ const namedAs = findNamingWord(finding, words, context);
7212
+ if (namedAs === null) return null;
7213
+ named.push({
7214
+ kind: finding.kind,
7215
+ target: finding.target,
7216
+ namedAs
7217
+ });
7218
+ }
7219
+ return named.length === 0 ? null : named;
7220
+ }
7221
+ function splitMessageWords(message) {
7222
+ return [...[...message.matchAll(/(?<quote>["'`])(?<text>[^"'`\n]+?)\k<quote>/gu)].map((match) => match.groups?.["text"] ?? ""), ...message.split(/[\s"'`()<>[\]{},;*]+/u)].map((word) => word.trim().replace(/[.:!?]+$/u, "")).filter((word) => word !== "");
7223
+ }
7224
+ function findNamingWord(finding, words, context) {
7225
+ const grant = finding.grant;
7226
+ if (grant === void 0) return null;
7227
+ if (grant.kind === "pull-request") return findPullRequestWord(grant.number, words);
7228
+ if (grant.kind === "path") return findPathWord(finding.target, words, context.home);
7229
+ if (PROTECTED_BRANCH_NAMES.has(finding.target) || finding.target === context.defaultBranch) return null;
7230
+ return words.find((word) => word === finding.target) ?? null;
7231
+ }
7232
+ const PULL_REQUEST_NOUNS = /^(?:pr|pull|request)$/iu;
7233
+ function findPullRequestWord(number, words) {
7234
+ for (const [index, word] of words.entries()) {
7235
+ if (word === `#${number}` || new RegExp(`^(?:pr|pull)#?${number}$`, "iu").test(word) || word.endsWith(`/pull/${number}`)) return word;
7236
+ const previous = words[index - 1] ?? "";
7237
+ if (word === String(number) && PULL_REQUEST_NOUNS.test(previous)) return `${previous} ${word}`;
7238
+ }
7239
+ return null;
7240
+ }
7241
+ function findPathWord(path, words, home) {
7242
+ const worktree = findWorktreeName(path);
7243
+ for (const word of words) {
7244
+ if (worktree !== null && word.split("/").includes(worktree)) return word;
7245
+ const named = resolveMessagePath(word, home);
7246
+ if (named !== null && isUnder(path, named) && !isUnder(home, named) && findWorktreeName(named) === worktree) return word;
7247
+ }
7248
+ return null;
7249
+ }
7250
+ function findWorktreeName(path) {
7251
+ return [...path.matchAll(/\/\.(?:claude\/)?worktrees\/(?<name>[^/]+)/gu)].at(-1)?.groups?.["name"] ?? null;
7252
+ }
7253
+ function resolveMessagePath(word, home) {
7254
+ const expanded = word.startsWith("~/") ? join(home, word.slice(2)) : word;
7255
+ if (!expanded.startsWith("/")) return null;
7256
+ const path = normalize(expanded);
7257
+ return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
7258
+ }
7259
+ function isUnder(path, root) {
7260
+ return path === root || root === "/" || path.startsWith(`${root}/`);
7261
+ }
7262
+ //#endregion
6980
7263
  //#region src/diagnostics/write-action-diagnostic.ts
6981
7264
  async function writeActionDiagnostic(payload, entry, host, warnings) {
6982
7265
  const path = host.env["AUTO_MODE_DIAGNOSTICS_PATH"] ?? join(resolveXDGDir(host, "state"), "actions.jsonl");
6983
7266
  if (path === "") return;
7267
+ const agentID = payload.decisionContext?.agentID ?? null;
7268
+ const agentKey = agentID === null ? payload.sessionID : `${payload.sessionID}\0${agentID}`;
6984
7269
  const record = {
6985
- schemaVersion: 4,
7270
+ schemaVersion: 6,
6986
7271
  time: (/* @__PURE__ */ new Date()).toISOString(),
6987
7272
  invocationID: entry.invocationID,
6988
7273
  sessionHash: toHash(payload.sessionID),
7274
+ agentHash: toHash(agentKey),
6989
7275
  actionHash: payload.toolUseID === void 0 ? null : toHash(payload.toolUseID),
6990
7276
  status: entry.status,
6991
7277
  verdict: entry.verdict ?? null,
@@ -6993,7 +7279,8 @@ async function writeActionDiagnostic(payload, entry, host, warnings) {
6993
7279
  denials: entry.denials ?? null,
6994
7280
  escalation: entry.escalation ?? false,
6995
7281
  diagnostics: entry.diagnostics ?? null,
6996
- judge: entry.judge ?? null
7282
+ judge: entry.judge ?? null,
7283
+ grant: entry.grant ?? null
6997
7284
  };
6998
7285
  try {
6999
7286
  await mkdir(dirname(path), {
@@ -7009,6 +7296,52 @@ function toHash(value) {
7009
7296
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
7010
7297
  }
7011
7298
  //#endregion
7299
+ //#region src/git/build-repository-context.ts
7300
+ function buildRepositoryContext(facts) {
7301
+ if (facts.head === null) return null;
7302
+ return {
7303
+ cwd: facts.cwd,
7304
+ branch: facts.head.branch,
7305
+ defaultBranch: facts.head.defaultBranch,
7306
+ remotes: facts.remotes.flatMap((remote) => {
7307
+ const url = normalizeRemoteURL(remote.url);
7308
+ return url === null ? [] : [{
7309
+ name: remote.name,
7310
+ url
7311
+ }];
7312
+ })
7313
+ };
7314
+ }
7315
+ function normalizeRemoteURL(url) {
7316
+ if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(url)) {
7317
+ const parsed = URL.parse(url);
7318
+ if (parsed === null) return null;
7319
+ parsed.username = "";
7320
+ parsed.password = "";
7321
+ parsed.search = "";
7322
+ parsed.hash = "";
7323
+ return parsed.href;
7324
+ }
7325
+ const slash = url.indexOf("/");
7326
+ const end = slash === -1 ? url.length : slash;
7327
+ const at = url.lastIndexOf("@", end);
7328
+ return at === -1 ? url : url.slice(at + 1);
7329
+ }
7330
+ //#endregion
7331
+ //#region src/git/build-repository-evidence.ts
7332
+ function buildRepositoryEvidence(facts, taskScope) {
7333
+ const context = buildRepositoryContext(facts);
7334
+ if (taskScope === void 0) return context;
7335
+ return {
7336
+ ...context ?? {
7337
+ cwd: facts.cwd,
7338
+ branch: null,
7339
+ defaultBranch: null
7340
+ },
7341
+ taskScope
7342
+ };
7343
+ }
7344
+ //#endregion
7012
7345
  //#region src/git/load-checkout-remotes.ts
7013
7346
  async function loadCheckoutRemotes(commonDir) {
7014
7347
  const config = await readFile(join(commonDir, "config"), "utf8").catch(() => "");
@@ -7083,11 +7416,6 @@ function parseReference(content, prefix) {
7083
7416
  return branch !== "" && branch.length <= 256 && !/\s/u.test(branch) ? branch : null;
7084
7417
  }
7085
7418
  //#endregion
7086
- //#region mods/auto-mode/contract/find-current-direct-user-message.ts
7087
- function findCurrentDirectUserMessage(message) {
7088
- return message === null || message === void 0 || message.freshness === "stale" ? null : message.text;
7089
- }
7090
- //#endregion
7091
7419
  //#region src/policy/resolve-shipped-policy-dir.ts
7092
7420
  function resolveShippedPolicyDir(startDir = import.meta.dirname, stopDir = parse(startDir).root) {
7093
7421
  let dir = startDir;
@@ -7121,6 +7449,10 @@ function buildJudgeMessage(input) {
7121
7449
  text: input.rule.text
7122
7450
  },
7123
7451
  action: input.action,
7452
+ ...input.containment === void 0 ? {} : {
7453
+ containment: input.containment.reason,
7454
+ namedTargets: input.containment.targets
7455
+ },
7124
7456
  lastDirectUserMessage: input.lastUserMessage,
7125
7457
  repository: input.repositoryContext
7126
7458
  };
@@ -7496,11 +7828,11 @@ function getReasonTemplate(rule) {
7496
7828
  clears: "The replacement policy defines what clears it."
7497
7829
  })).exhaustive();
7498
7830
  }
7499
- function buildJudgedVerdict(rule, basis, reply, overturns) {
7831
+ function buildJudgedVerdict(rule, basis, reply, overturns, heldReason) {
7500
7832
  const template = {
7501
7833
  kind: "deny",
7502
7834
  rule: rule.name,
7503
- reason: buildDenyReason(rule, basis)
7835
+ reason: heldReason ?? buildDenyReason(rule, basis)
7504
7836
  };
7505
7837
  if (reply === null || reply.kind === "unreadable") return {
7506
7838
  verdict: template,
@@ -7696,6 +8028,7 @@ async function sendJudgeMessage(provider, request, transport, signal) {
7696
8028
  async function classifyWithJudge(payload, evidence, judge, options) {
7697
8029
  const start = performance.now();
7698
8030
  const now = options.now ?? Date.now;
8031
+ const overturns = evidence.containment === void 0 ? options.overturns : "consent";
7699
8032
  let failure = null;
7700
8033
  let verdict;
7701
8034
  try {
@@ -7714,7 +8047,8 @@ async function classifyWithJudge(payload, evidence, judge, options) {
7714
8047
  input: payload.toolInput
7715
8048
  },
7716
8049
  lastUserMessage: directMessage,
7717
- repositoryContext: evidence.repositoryContext
8050
+ repositoryContext: evidence.repositoryContext,
8051
+ containment: evidence.containment
7718
8052
  });
7719
8053
  const remainingMs = options.deadlineAt === void 0 ? judge.timeoutMs : options.deadlineAt - now();
7720
8054
  failure = "timeout";
@@ -7733,9 +8067,9 @@ async function classifyWithJudge(payload, evidence, judge, options) {
7733
8067
  throw error;
7734
8068
  }));
7735
8069
  failure = reply.kind === "unreadable" ? "unreadable" : null;
7736
- verdict = buildJudgedVerdict(evidence.rule, evidence.basis, reply, options.overturns);
8070
+ verdict = buildJudgedVerdict(evidence.rule, evidence.basis, reply, overturns, evidence.containment?.reason);
7737
8071
  } catch {
7738
- verdict = buildJudgedVerdict(evidence.rule, evidence.basis, null, options.overturns);
8072
+ verdict = buildJudgedVerdict(evidence.rule, evidence.basis, null, overturns, evidence.containment?.reason);
7739
8073
  }
7740
8074
  return {
7741
8075
  ...verdict,
@@ -7763,52 +8097,6 @@ function buildTaskScopeSummary(scope) {
7763
8097
  };
7764
8098
  }
7765
8099
  //#endregion
7766
- //#region src/git/build-repository-context.ts
7767
- function buildRepositoryContext(facts) {
7768
- if (facts.head === null) return null;
7769
- return {
7770
- cwd: facts.cwd,
7771
- branch: facts.head.branch,
7772
- defaultBranch: facts.head.defaultBranch,
7773
- remotes: facts.remotes.flatMap((remote) => {
7774
- const url = normalizeRemoteURL(remote.url);
7775
- return url === null ? [] : [{
7776
- name: remote.name,
7777
- url
7778
- }];
7779
- })
7780
- };
7781
- }
7782
- function normalizeRemoteURL(url) {
7783
- if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(url)) {
7784
- const parsed = URL.parse(url);
7785
- if (parsed === null) return null;
7786
- parsed.username = "";
7787
- parsed.password = "";
7788
- parsed.search = "";
7789
- parsed.hash = "";
7790
- return parsed.href;
7791
- }
7792
- const slash = url.indexOf("/");
7793
- const end = slash === -1 ? url.length : slash;
7794
- const at = url.lastIndexOf("@", end);
7795
- return at === -1 ? url : url.slice(at + 1);
7796
- }
7797
- //#endregion
7798
- //#region src/git/build-repository-evidence.ts
7799
- function buildRepositoryEvidence(facts, taskScope) {
7800
- const context = buildRepositoryContext(facts);
7801
- if (taskScope === void 0) return context;
7802
- return {
7803
- ...context ?? {
7804
- cwd: facts.cwd,
7805
- branch: null,
7806
- defaultBranch: null
7807
- },
7808
- taskScope
7809
- };
7810
- }
7811
- //#endregion
7812
8100
  //#region src/git/load-mcp-servers.ts
7813
8101
  const serverSchema = looseObject({
7814
8102
  type: string().optional(),
@@ -8365,6 +8653,11 @@ function toMessage(error) {
8365
8653
  return error instanceof Error ? error.message : String(error);
8366
8654
  }
8367
8655
  //#endregion
8656
+ //#region src/policy/read-containment-rule.ts
8657
+ async function readContainmentRule(policyDir = resolveShippedPolicyDir()) {
8658
+ return (await readFile(join(policyDir, "containment.md"), "utf8")).trim();
8659
+ }
8660
+ //#endregion
8368
8661
  //#region src/policy/read-denial-guidance.ts
8369
8662
  async function readDenialGuidance(policyDir = resolveShippedPolicyDir()) {
8370
8663
  return (await readFile(join(policyDir, "denial.md"), "utf8")).trim();
@@ -8564,7 +8857,7 @@ function mergeScopeFacts(all) {
8564
8857
  //#region src/scope/build-task-scope.ts
8565
8858
  function buildTaskScope(input) {
8566
8859
  const merged = mergeScopeFacts(input.facts);
8567
- const branches = merged.branches.filter((branch) => branch !== input.defaultBranch && !(input.defaultBranch === null && DEFAULT_NAMES.has(branch)));
8860
+ const branches = merged.branches.filter((branch) => branch !== input.defaultBranch && !(input.defaultBranch === null && PROTECTED_BRANCH_NAMES.has(branch)));
8568
8861
  const repositories = new Set(input.remotes.map((remote) => toRepositorySlug(remote.url)));
8569
8862
  const pullRequests = merged.pullRequests.filter((pull) => branches.includes(pull.head) && repositories.has(pull.repository)).map((pull) => ({
8570
8863
  number: pull.number,
@@ -8580,12 +8873,6 @@ function buildTaskScope(input) {
8580
8873
  pathGlobs: merged.pathGlobs
8581
8874
  };
8582
8875
  }
8583
- const DEFAULT_NAMES = /* @__PURE__ */ new Set([
8584
- "main",
8585
- "master",
8586
- "trunk",
8587
- "develop"
8588
- ]);
8589
8876
  //#endregion
8590
8877
  //#region src/scope/build-atc-scope-facts.ts
8591
8878
  function buildAtcScopeFacts(record, isInRepository) {
@@ -8818,7 +9105,8 @@ async function classifyAction(request, config, options = {}) {
8818
9105
  ...outcome.denials === void 0 ? {} : { denials: outcome.denials },
8819
9106
  ...outcome.escalation === void 0 ? {} : { escalation: outcome.escalation },
8820
9107
  ...outcome.diagnostics === void 0 ? {} : { diagnostics: outcome.diagnostics },
8821
- ...outcome.judge === void 0 ? {} : { judge: outcome.judge }
9108
+ ...outcome.judge === void 0 ? {} : { judge: outcome.judge },
9109
+ ...outcome.grant === void 0 ? {} : { grant: outcome.grant }
8822
9110
  }, host, stderr);
8823
9111
  return outcome;
8824
9112
  }
@@ -8874,17 +9162,51 @@ async function classifyStages(request, config, options) {
8874
9162
  findCheckout: findActionCheckout
8875
9163
  }, config, host, options.stderr);
8876
9164
  const containment = scope === null ? null : checkContainment(request, scope, host.scratchPaths);
8877
- if (containment !== null) {
8878
- const guidance = await tryReadDenialGuidance();
9165
+ const stageOptions = {
9166
+ ...options,
9167
+ claudeSettings,
9168
+ findCheckout: findActionCheckout
9169
+ };
9170
+ const facts = {
9171
+ local,
9172
+ scope,
9173
+ checkout,
9174
+ hasConfiguredDenies
9175
+ };
9176
+ if (scope === null || containment === null) return classifyPastContainment(request, config, stageOptions, facts);
9177
+ const containmentNote = `denied by the containment check: ${containment.findings.map((finding) => finding.target).join(", ")}`;
9178
+ const [grant, guidance] = await Promise.all([classifyUserNamedGrant(request, config, options, {
9179
+ containment,
9180
+ scope,
9181
+ checkout
9182
+ }), tryReadDenialGuidance()]);
9183
+ if (grant === null || grant.verdict.kind === "deny") {
9184
+ const deny = grant?.verdict.kind === "deny" ? grant.verdict : {
9185
+ rule: containment.rule,
9186
+ reason: containment.reason
9187
+ };
8879
9188
  return {
8880
- verdict: buildGuidedDeny(containment.rule, containment.reason, guidance),
9189
+ verdict: buildGuidedDeny(deny.rule, deny.reason, guidance),
8881
9190
  decidingStage: "containment",
8882
- note: `denied by the containment check: ${containment.findings.map((finding) => finding.target).join(", ")}`,
8883
- status: "deny"
9191
+ note: grant === null ? containmentNote : `${containmentNote}; judge ${grant.status} (${grant.diagnostics.elapsedMs}ms)`,
9192
+ status: "deny",
9193
+ ...grant === null ? {} : { grant: grant.diagnostics }
8884
9194
  };
8885
9195
  }
8886
- if (local.kind === "allow") return buildLocalAllow(local.exception);
8887
- const edit = hasConfiguredDenies || scope === null ? null : await tryClassifyEdit(request, scope, host, { findCheckout: findActionCheckout });
9196
+ const outcome = await classifyPastContainment(request, config, stageOptions, facts);
9197
+ return {
9198
+ ...outcome,
9199
+ note: `containment cleared by the judge for the targets the user named (${grant.diagnostics.elapsedMs}ms); ${outcome.note}`,
9200
+ grant: grant.diagnostics
9201
+ };
9202
+ }
9203
+ async function classifyPastContainment(request, config, options, facts) {
9204
+ const host = options.host;
9205
+ const scope = facts.scope;
9206
+ const findActionCheckout = options.findCheckout;
9207
+ const claudeSettings = options.claudeSettings;
9208
+ if (facts.local.kind === "allow") return buildLocalAllow(facts.local.exception);
9209
+ const edit = facts.hasConfiguredDenies || scope === null ? null : await tryClassifyEdit(request, scope, host, { findCheckout: findActionCheckout });
8888
9210
  if (edit?.kind === "bypass") return {
8889
9211
  verdict: { kind: "allow" },
8890
9212
  decidingStage: "bypass",
@@ -8903,7 +9225,7 @@ async function classifyStages(request, config, options) {
8903
9225
  taskScope,
8904
9226
  host,
8905
9227
  claudeSettings,
8906
- checkout: checkout ?? void 0,
9228
+ checkout: facts.checkout ?? void 0,
8907
9229
  findCheckout: findActionCheckout
8908
9230
  }), tryReadDenialGuidance()]);
8909
9231
  const judge = config.judge ?? null;
@@ -8933,6 +9255,37 @@ async function classifyStages(request, config, options) {
8933
9255
  };
8934
9256
  }
8935
9257
  const READ_ONLY_EXCEPTION = "Read-only actions";
9258
+ async function classifyUserNamedGrant(request, config, options, facts) {
9259
+ const containment = facts.containment;
9260
+ const judge = config.judge ?? null;
9261
+ const message = findCurrentDirectUserMessage(request.decisionContext?.lastDirectUserMessage);
9262
+ if (judge === null || message === null || options.localOnly === true) return null;
9263
+ const targets = findUserNamedGrant(containment.findings, message, {
9264
+ home: facts.scope.home,
9265
+ defaultBranch: facts.checkout?.head?.defaultBranch ?? null
9266
+ });
9267
+ if (targets === null) return null;
9268
+ const text = await readContainmentRule().catch(() => null);
9269
+ if (text === null) return null;
9270
+ return classifyWithJudge(request, {
9271
+ rule: {
9272
+ name: containment.rule,
9273
+ tier: "soft",
9274
+ source: "shipped",
9275
+ text
9276
+ },
9277
+ basis: "matched",
9278
+ repositoryContext: facts.checkout === null ? null : buildRepositoryEvidence(facts.checkout, buildTaskScopeSummary(facts.scope)),
9279
+ containment: {
9280
+ reason: containment.reason,
9281
+ targets
9282
+ }
9283
+ }, judge, {
9284
+ ...options,
9285
+ rulesPath: config.rulesPath,
9286
+ overturns: "consent"
9287
+ });
9288
+ }
8936
9289
  async function tryLoadTaskScope(request, config, host, stderr) {
8937
9290
  try {
8938
9291
  return await loadTaskScope({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-mode",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "A permission classifier library and Claude Code mod, so agents that are not Claude can run unattended",
5
5
  "keywords": [
6
6
  "agent",
@@ -0,0 +1,17 @@
1
+ ### Outside Task Scope
2
+ Writing to a target the task does not own: a path outside the task's worktrees,
3
+ a branch the task did not make, or a pull request the task did not open.
4
+ auto-mode's containment check found each target. The evidence field
5
+ `containment` holds the check's finding, which names every target and what the
6
+ task owns, and `namedTargets`, which pairs each target with the word of the last
7
+ direct user message that names it. The task scope is a fact the check read, not
8
+ a reading of the action, so this rule never clears as a misread.
9
+
10
+ A name alone is not consent. The message must ask for this operation — write,
11
+ edit, create, delete, remove, move, push, merge, or comment — on every listed
12
+ target. A target the message names only as a source to read, copy, port, or
13
+ compare from, as a place to look, or as a reference is not consent. Neither is a
14
+ request to check or report on it, a different or narrower operation on it, or a
15
+ word that happens to match the target's name but means something else.
16
+
17
+ **Must name:** the operation and every target.