auto-mode 0.17.0 → 0.19.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/README.md CHANGED
@@ -6,84 +6,52 @@
6
6
  </p>
7
7
  </div>
8
8
 
9
- **auto-mode** judges the tool calls that Claude Code would prompt for. It is a core library and a
10
- Claude Code mod. The mod returns allow, deny, or no verdict before the permission dialog appears.
11
- Local rules settle read-only actions and build cleanup; Jev evaluates the remaining actions against
12
- a written policy.
9
+ **auto-mode** tries to mitigate the risk of unattended non-Claude agents inside Claude Code.
13
10
 
14
- The Jev request includes the policy, your configured environment and permissions, the complete
15
- proposed action, and the last direct user message. It excludes the rest of the session. Configure
16
- standing permissions upfront; earlier conversational grants and restrictions are unavailable to the
17
- classifier.
11
+ auto-mode runs as a Claude Code mod, and its operating model is loosely modelled on Anthropic's
12
+ native auto mode. Instead of one model, it pairs a fast decision model (such as Jev) with a
13
+ traditional classifier-judge model (such as Haiku) to cover the first one's shortcomings.
18
14
 
19
15
  ## Install
20
16
 
17
+ auto-mode needs node 24 or later, Claude Code, and a TypeSafe API key for Jev.
18
+
21
19
  ```sh
22
20
  npm i -g auto-mode
23
21
  claude --plugin-dir "$(npm root -g)/auto-mode/mods/auto-mode"
24
22
  ```
25
23
 
26
- The package ships the mod in `mods/auto-mode`. The mod runs the `auto-mode` executable on `PATH`;
27
- its `command` option takes another executable. [Claude permission mod](./docs/guides/claude-mod.md)
28
- covers the mod, its time limits, and a trial session.
29
-
30
- Create the classifier configuration:
31
-
32
- ```json
33
- {
34
- "classifiers": { "jev": { "apiKeyEnv": "TYPESAFE_API_KEY" } },
35
- "decision": { "classifier": "jev" }
36
- }
37
- ```
38
-
39
- Save it as `~/.config/auto-mode/config.json` and export your TypeSafe API key, or set the entry's
40
- `apiKeyCommand` to a command that prints it. [Configuration](./docs/guides/configuration.md) covers
41
- the registries and credentials.
42
-
43
- ## Standing permissions
44
-
45
- Jev imports explicit entries from `autoMode.environment`, `autoMode.allow`, `autoMode.soft_deny`,
46
- and `autoMode.hard_deny` in your user Claude settings. It imports no credential values or shell
47
- permission patterns. `$defaults` refers to auto-mode's shipped policy; it does not expand Claude's
48
- built-in classifier rules.
49
-
50
- Configured allows clear soft blocks. Hard blocks take priority over all allows and conversational
51
- consent. [False-positive clarification](./docs/guides/policy.md#false-positive-clarification) can
52
- clear Policy Tampering and Audit Tampering. The last direct user message can supply the exact action
53
- and target that a soft block needs; replies such as “go ahead” cannot supply an unseen proposal.
54
-
55
- Read [the policy](./policy/rules.md) before you enable the mod.
56
- [Writing a policy](./docs/guides/policy.md) covers the precedence and the limits of an action-only
57
- assessment.
58
-
59
- ## Decisions and failures
60
-
61
- Jev evaluates each named rule with a typed choice between allow and block. auto-mode allows the
62
- action unless some rule's block probability reaches the threshold, 0.2 by default. Every denial
63
- names the rule with the highest block probability, the harm, and what clears it, and the agent
64
- continues on another path. The threshold is configurable and needs evaluation against your actions.
65
-
66
- A missing key, failed API call, malformed response, or oversized request follows
67
- `decision.onFailure`. The default `defer` writes no verdict and keeps the prompt; `deny` fails
68
- closed. Failures write a diagnostic to stderr. auto-mode never truncates a Jev action to make it
69
- fit.
70
-
71
- ## Commands
72
-
73
- | Command | Effect |
74
- | ---------------------------- | ----------------------------------------- |
75
- | `auto-mode run` | Read a mod request and write a verdict |
76
- | `auto-mode run --explain` | Write decision details to stderr |
77
- | `auto-mode run --local-only` | Skip the model tier |
78
- | `auto-mode run --jev-only` | Require Jev with a 5-second API timeout |
79
- | `auto-mode print-prompt` | Print the selected provider's base policy |
80
-
81
- The mod evaluates only ask decisions and preserves existing allow and deny decisions. It runs the
82
- CLI as a bounded child process with `--jev-only`. The `spark`, `claude`, `glm`, and `messages`
83
- classifier kinds use the Messages API and serve library callers.
24
+ Export your key as `TYPESAFE_API_KEY`, or point the configuration at a command that prints it.
25
+ [Claude permission mod](./docs/guides/claude-mod.md) covers the mod and a trial session you can
26
+ undo.
27
+
28
+ ## Features
29
+
30
+ - **Local rules** allow read-only calls and build-output cleanup without a model call
31
+ ([local rules](./docs/architecture/stages/local-rules.md)).
32
+ - **Containment** denies a write outside the task's own worktrees, branches and pull requests
33
+ ([containment](./docs/architecture/stages/containment.md)).
34
+ - **The edit bypass** allows an in-scope file edit once a secret scan finds nothing
35
+ ([the edit bypass](./docs/architecture/stages/edit-bypass.md)).
36
+ - **Jev** judges the remaining calls against the shipped rules and your own
37
+ ([Jev](./docs/architecture/stages/jev.md)).
38
+ - **The judge**, on by default, reviews every Jev deny, and overturns it when your last message
39
+ asked the main agent for exactly this action, or when no rule covers the action
40
+ ([the judge](./docs/architecture/stages/judge.md)).
41
+ - **The denial budget** hands a call back to you when an agent keeps getting denied
42
+ ([the denial budget](./docs/architecture/stages/denial-budget.md)).
43
+ - **Your own rules** from your Claude settings join the policy as block rules
44
+ ([settings and repository facts](./docs/architecture/evidence/settings-and-repository.md)).
45
+ - **Action diagnostics** record how each call was decided, without the command or the prompt
46
+ ([action diagnostics](./docs/guides/diagnostics.md)).
47
+
48
+ Read [the rules](./policy/rules.md) before you enable the mod.
49
+ [Writing a policy](./docs/guides/policy.md) covers how the rules clear and how to change them.
84
50
 
85
51
  ## Library
86
52
 
53
+ You can call auto-mode's core APIs from your own code:
54
+
87
55
  ```ts
88
56
  import { classifyAction, loadConfig, parseActionRequest } from 'auto-mode';
89
57
 
@@ -94,19 +62,14 @@ if (request !== null) {
94
62
  }
95
63
  ```
96
64
 
97
- `classifyAction` runs both tiers and returns the verdict, a note, and diagnostics. A request carries
98
- the session identity, the action, and the task context; `parseActionRequest` builds one from the
99
- mod's JSON.
65
+ ## Configuration
66
+
67
+ Read the [configuration](./docs/guides/configuration.md) guide, which covers every field and the
68
+ credentials.
100
69
 
101
70
  ## Documentation
102
71
 
103
- - [Architecture](./docs/architecture/overview.md) covers the request, decision combination, and the
104
- mod contract.
105
- - [Configuration](./docs/guides/configuration.md) covers every setting and the imported Claude
106
- rules.
107
- - [Claude permission mod](./docs/guides/claude-mod.md) covers the request, the verdict, and the time
108
- limits.
109
- - [Writing a policy](./docs/guides/policy.md) covers rule edits and evaluation.
72
+ [docs/](./docs/README.md) covers the architecture and the user guides.
110
73
 
111
74
  ## Licence
112
75
 
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as resolveStateDir, D as DEFAULT_SCOPE_SOURCES, E as DEFAULT_DENIAL_BUDGET, F as string, N as number, O as loadConfig, P as strictObject, a as loadSessionScope, b as splitCommandWords, f as loadRepositoryContext, g as loadPolicy, i as resolveSessionScopePath, j as readHostEnvironment, n as parseActionRequest, o as EMPTY_SESSION_SCOPE, p as loadCheckoutRemotes, r as classifyAction, t as renderVerdict, v as toRepositorySlug, w as findCheckout, y as splitShellCommand } from "./render-verdict-Bx9Lw7gO.js";
2
+ import { C as resolveStateDir, D as DEFAULT_SCOPE_SOURCES, E as DEFAULT_DENIAL_BUDGET, F as string, N as number, O as loadConfig, P as strictObject, a as loadSessionScope, b as splitCommandWords, f as loadRepositoryContext, g as loadPolicy, i as resolveSessionScopePath, j as readHostEnvironment, n as parseActionRequest, o as EMPTY_SESSION_SCOPE, p as loadCheckoutRemotes, r as classifyAction, t as renderVerdict, v as toRepositorySlug, w as findCheckout, y as splitShellCommand } from "./render-verdict-CcVLBFKT.js";
3
3
  import { appendFile, chmod, lstat, mkdir, open, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path";
5
5
  import { spawn } from "node:child_process";
@@ -274,6 +274,22 @@ function parseScopeRecordRequest(body) {
274
274
  return parsed.success ? parsed.data : null;
275
275
  }
276
276
  //#endregion
277
+ //#region src/request/to-mod-request.ts
278
+ function toModRequest(request) {
279
+ const context = request.decisionContext;
280
+ return {
281
+ sessionID: request.sessionID,
282
+ ...request.toolUseID === void 0 ? {} : { toolUseID: request.toolUseID },
283
+ cwd: request.cwd,
284
+ toolName: request.toolName,
285
+ toolInput: request.toolInput,
286
+ context: {
287
+ ...context,
288
+ omittedTaskContext: [...context.omittedTaskContext]
289
+ }
290
+ };
291
+ }
292
+ //#endregion
277
293
  //#region src/scope/read-pull-request.ts
278
294
  const LOOKUP_TIMEOUT_MS = 5e3;
279
295
  function readPullRequest(repository, number, options = {}) {
@@ -335,6 +351,17 @@ function collectPullRequestAddresses(text) {
335
351
  }
336
352
  const PULL_REQUEST_URL = /https:\/\/(?<repository>[^/\s]+\/[^/\s]+\/[^/\s]+)\/pull\/(?<number>[1-9]\d*)\b/gu;
337
353
  //#endregion
354
+ //#region mods/auto-mode/contract/scope-commands.ts
355
+ const SCOPE_COMMANDS = {
356
+ git: [
357
+ "worktree add",
358
+ "checkout",
359
+ "switch",
360
+ "branch"
361
+ ],
362
+ gh: ["pr create"]
363
+ };
364
+ //#endregion
338
365
  //#region src/scope/collect-scope-events.ts
339
366
  function collectScopeEvents(command, cwd, home) {
340
367
  const events = [];
@@ -344,7 +371,7 @@ function collectScopeEvents(command, cwd, home) {
344
371
  const [name, ...args] = splitCommandWords(segment.text);
345
372
  if (name === "cd") directory = hasPipe ? null : resolvePath(directory, args[0] ?? home, home);
346
373
  else if (name === "git" && directory !== null) events.push(...collectGitEvents(args, directory, home));
347
- else if (name === "gh" && directory !== null && args[0] === "pr" && args[1] === "create") events.push({
374
+ else if (name === "gh" && directory !== null && findScopeCommand(SCOPE_COMMANDS.gh, args) !== null) events.push({
348
375
  kind: "pull-request",
349
376
  directory,
350
377
  head: findOptionValue(args, ["--head", "-H"])
@@ -352,15 +379,6 @@ function collectScopeEvents(command, cwd, home) {
352
379
  }
353
380
  return events;
354
381
  }
355
- const BRANCH_CREATE_OPTIONS = /* @__PURE__ */ new Set([
356
- "-b",
357
- "-B",
358
- "-c",
359
- "-C",
360
- "--create",
361
- "--force-create"
362
- ]);
363
- const BRANCH_OTHER_OPTIONS = /^-(?:d|D|m|M|c|C|l|a|r|v|vv|u)$|^--(?:delete|move|copy|list|all|remotes|verbose|show-current|contains|no-contains|merged|no-merged|points-at|format|sort|set-upstream-to|unset-upstream|edit-description)/u;
364
382
  function collectGitEvents(args, cwd, home) {
365
383
  let directory = cwd;
366
384
  let index = 0;
@@ -368,34 +386,33 @@ function collectGitEvents(args, cwd, home) {
368
386
  if (args[index] === "-C") directory = resolvePath(directory, args[index + 1] ?? "", home);
369
387
  index += args[index] === "-C" || args[index] === "-c" ? 2 : 1;
370
388
  }
371
- const subcommand = args[index];
372
- const rest = args.slice(index + 1);
373
- if (directory === null) return [];
374
- if (subcommand === "worktree" && rest[0] === "add") {
375
- const path = findWorktreePath(rest.slice(1));
376
- const resolved = path === null ? null : resolvePath(directory, path, home);
377
- return resolved === null ? [] : [{
378
- kind: "worktree",
379
- path: resolved
380
- }];
381
- }
382
- if (subcommand === "checkout" || subcommand === "switch") {
383
- const name = findOptionValue(rest, [...BRANCH_CREATE_OPTIONS]);
384
- return name === null ? [] : [{
385
- kind: "branch",
386
- name,
387
- directory
388
- }];
389
- }
390
- if (subcommand === "branch" && !rest.some((word) => BRANCH_OTHER_OPTIONS.test(word))) {
391
- const name = rest.find((word) => !word.startsWith("-"));
392
- return name === void 0 || isUnresolved(name) ? [] : [{
393
- kind: "branch",
394
- name,
395
- directory
396
- }];
389
+ const found = findScopeCommand(SCOPE_COMMANDS.git, args.slice(index));
390
+ if (directory === null || found === null) return [];
391
+ return GIT_EVENT_COLLECTORS[found.command](found.rest, directory, home);
392
+ }
393
+ function findScopeCommand(commands, args) {
394
+ for (const command of commands) {
395
+ const words = command.split(" ");
396
+ if (words.every((word, index) => args[index] === word)) return {
397
+ command,
398
+ rest: args.slice(words.length)
399
+ };
397
400
  }
398
- return [];
401
+ return null;
402
+ }
403
+ const GIT_EVENT_COLLECTORS = {
404
+ "worktree add": collectWorktreeEvents,
405
+ checkout: collectSwitchEvents,
406
+ switch: collectSwitchEvents,
407
+ branch: collectBranchEvents
408
+ };
409
+ function collectWorktreeEvents(rest, directory, home) {
410
+ const path = findWorktreePath(rest);
411
+ const resolved = path === null ? null : resolvePath(directory, path, home);
412
+ return resolved === null ? [] : [{
413
+ kind: "worktree",
414
+ path: resolved
415
+ }];
399
416
  }
400
417
  const WORKTREE_VALUE_OPTIONS = /* @__PURE__ */ new Set([
401
418
  "-b",
@@ -410,6 +427,32 @@ function findWorktreePath(args) {
410
427
  }
411
428
  return null;
412
429
  }
430
+ const BRANCH_CREATE_OPTIONS = /* @__PURE__ */ new Set([
431
+ "-b",
432
+ "-B",
433
+ "-c",
434
+ "-C",
435
+ "--create",
436
+ "--force-create"
437
+ ]);
438
+ const BRANCH_OTHER_OPTIONS = /^-(?:d|D|m|M|c|C|l|a|r|v|vv|u)$|^--(?:delete|move|copy|list|all|remotes|verbose|show-current|contains|no-contains|merged|no-merged|points-at|format|sort|set-upstream-to|unset-upstream|edit-description)/u;
439
+ function collectSwitchEvents(rest, directory) {
440
+ const name = findOptionValue(rest, [...BRANCH_CREATE_OPTIONS]);
441
+ return name === null ? [] : [{
442
+ kind: "branch",
443
+ name,
444
+ directory
445
+ }];
446
+ }
447
+ function collectBranchEvents(rest, directory) {
448
+ if (rest.some((word) => BRANCH_OTHER_OPTIONS.test(word))) return [];
449
+ const name = rest.find((word) => !word.startsWith("-"));
450
+ return name === void 0 || isUnresolved(name) ? [] : [{
451
+ kind: "branch",
452
+ name,
453
+ directory
454
+ }];
455
+ }
413
456
  function findOptionValue(args, options) {
414
457
  const index = args.findIndex((word) => options.includes(word));
415
458
  const value = index === -1 ? void 0 : args[index + 1];
@@ -704,7 +747,7 @@ async function run(io, explain, localOnly, jevOnly, deadline) {
704
747
  const captured = await tryWriteRequestCapture(resolveCaptureDir(config.capture, host), {
705
748
  schemaVersion: 1,
706
749
  time: (/* @__PURE__ */ new Date()).toISOString(),
707
- request: body,
750
+ request: toModRequest(request),
708
751
  verdict: plan.verdict,
709
752
  decidingStage: plan.escalation ? "budget" : outcome.decidingStage,
710
753
  escalation: plan.escalation
package/dist/index.d.ts CHANGED
@@ -1,32 +1,48 @@
1
- //#region src/request/types.d.ts
1
+ //#region mods/auto-mode/contract/message-origins.d.ts
2
+ declare const MESSAGE_ORIGINS: readonly ['composer', 'bridge', 'sdk'];
3
+ //#endregion
4
+ //#region mods/auto-mode/contract/types.d.ts
5
+ type MessageOrigin = (typeof MESSAGE_ORIGINS)[number];
2
6
  interface UserTask {
3
7
  readonly text: string;
4
- readonly origin: 'composer' | 'bridge' | 'sdk';
8
+ readonly origin: MessageOrigin;
5
9
  }
6
10
  interface DirectUserMessage extends UserTask {
7
11
  readonly freshness?: 'stale' | undefined;
8
12
  }
9
- interface DecisionContext {
13
+ interface DelegatedTask {
14
+ readonly text: string;
15
+ readonly origin: 'agent.spawn';
16
+ }
17
+ interface TaskOmission {
18
+ readonly field: 'originalUserTask' | 'delegatedTask';
19
+ readonly reason: 'unavailable' | 'budget';
20
+ }
21
+ interface ModContext {
10
22
  readonly agentID: string | null;
11
23
  readonly originalUserTask: UserTask | null;
12
- readonly delegatedTask: {
13
- readonly text: string;
14
- readonly origin: 'agent.spawn';
15
- } | null;
24
+ readonly delegatedTask: DelegatedTask | null;
16
25
  readonly lastDirectUserMessage: DirectUserMessage | null;
17
- readonly omittedTaskContext: readonly {
18
- readonly field: 'originalUserTask' | 'delegatedTask';
19
- readonly reason: 'unavailable' | 'budget';
20
- }[];
26
+ readonly omittedTaskContext: TaskOmission[];
21
27
  }
22
- interface ActionRequest {
28
+ interface ModRequest {
23
29
  readonly sessionID: string;
24
30
  readonly toolUseID?: string | undefined;
25
31
  readonly cwd: string;
26
32
  readonly toolName: string;
27
33
  readonly toolInput: Readonly<Record<string, unknown>>;
28
- readonly decisionContext?: DecisionContext | undefined;
34
+ readonly context: ModContext;
29
35
  }
36
+ //#endregion
37
+ //#region src/request/types.d.ts
38
+ type DeepReadonly<T> = T extends readonly (infer Item)[] ? readonly DeepReadonly<Item>[] : T extends object ? { readonly [Key in keyof T]: DeepReadonly<T[Key]>; } : T;
39
+ type DecisionContext = DeepReadonly<ModContext>;
40
+ type ActionRequest = DeepReadonly<Omit<ModRequest, 'context'>> & {
41
+ readonly decisionContext?: DecisionContext | undefined;
42
+ };
43
+ type ParsedActionRequest = ActionRequest & {
44
+ readonly decisionContext: DecisionContext;
45
+ };
30
46
  type Verdict = {
31
47
  readonly kind: 'allow';
32
48
  } | {
@@ -349,7 +365,7 @@ interface PolicyPaths {
349
365
  export declare function loadPolicy(paths?: PolicyPaths, framework?: 'classifier.md' | 'decision.md' | 'judge.md'): Promise<string>;
350
366
  //#endregion
351
367
  //#region src/request/parse-action-request.d.ts
352
- export declare function parseActionRequest(body: unknown): ActionRequest | null;
368
+ export declare function parseActionRequest(body: unknown): ParsedActionRequest | null;
353
369
  //#endregion
354
370
  //#region src/request/render-verdict.d.ts
355
371
  export declare function renderVerdict(verdict: Verdict): string;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as resolveConfigPath, M as PRESETS, O as loadConfig, S as tryClassifyEdit, T as DEFAULT_CONFIG, _ as checkContainment, c as classifyLocally, d as sendDecision, g as loadPolicy, h as buildDecisionRequest, k as resolveApiKey, l as classifyWithModel, m as DecisionRequestError, n as parseActionRequest, r as classifyAction, s as buildTaskScope, t as renderVerdict, u as parseModelVerdict, x as loadClaudeSettings } from "./render-verdict-Bx9Lw7gO.js";
1
+ import { A as resolveConfigPath, M as PRESETS, O as loadConfig, S as tryClassifyEdit, T as DEFAULT_CONFIG, _ as checkContainment, c as classifyLocally, d as sendDecision, g as loadPolicy, h as buildDecisionRequest, k as resolveApiKey, l as classifyWithModel, m as DecisionRequestError, n as parseActionRequest, r as classifyAction, s as buildTaskScope, t as renderVerdict, u as parseModelVerdict, x as loadClaudeSettings } from "./render-verdict-CcVLBFKT.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;
@@ -5684,7 +5684,7 @@ const DEFAULT_SCOPE_SOURCES = {
5684
5684
  const DEFAULT_CONFIG = {
5685
5685
  provider: DEFAULT_PROVIDER,
5686
5686
  judge: DEFAULT_JUDGE,
5687
- judgeOverturns: "consent",
5687
+ judgeOverturns: "any",
5688
5688
  onFailure: "defer",
5689
5689
  blockThreshold: .2,
5690
5690
  denialBudget: DEFAULT_DENIAL_BUDGET,
@@ -5758,7 +5758,7 @@ function buildConfig(json, path, home) {
5758
5758
  return {
5759
5759
  provider,
5760
5760
  judge,
5761
- judgeOverturns: decision.judgeOverturns ?? "consent",
5761
+ judgeOverturns: decision.judgeOverturns ?? "any",
5762
5762
  scopeSources: configuredSources ?? DEFAULT_SCOPE_SOURCES,
5763
5763
  classifierPath: expandHomePath(policy.frameworkPath ?? void 0, home),
5764
5764
  rulesPath: expandHomePath(policy.rulesPath ?? void 0, home),
@@ -6652,6 +6652,11 @@ function formatList(one, many, values) {
6652
6652
  return `the ${values.length === 1 ? one : many} ${values.join(", ")}`;
6653
6653
  }
6654
6654
  //#endregion
6655
+ //#region mods/auto-mode/contract/find-current-direct-user-message.ts
6656
+ function findCurrentDirectUserMessage(message) {
6657
+ return message === null || message === void 0 || message.freshness === "stale" ? null : message.text;
6658
+ }
6659
+ //#endregion
6655
6660
  //#region src/policy/resolve-shipped-policy-dir.ts
6656
6661
  function resolveShippedPolicyDir(startDir = import.meta.dirname, stopDir = parse(startDir).root) {
6657
6662
  let dir = startDir;
@@ -6674,13 +6679,6 @@ async function loadPolicy(paths = {}, framework = "classifier.md") {
6674
6679
  return classifier.replace(RULES_MARKER, rules.trim());
6675
6680
  }
6676
6681
  //#endregion
6677
- //#region src/request/find-current-direct-user-message.ts
6678
- function findCurrentDirectUserMessage(context) {
6679
- if (context === void 0 || context.agentID !== null) return null;
6680
- const message = context.lastDirectUserMessage;
6681
- return message === null || message.freshness === "stale" ? null : message.text;
6682
- }
6683
- //#endregion
6684
6682
  //#region src/judge/build-judge-message.ts
6685
6683
  function buildJudgeMessage(input) {
6686
6684
  const evidence = {
@@ -7289,7 +7287,7 @@ async function classifyWithJudge(payload, evidence, judge, options) {
7289
7287
  if (judge.protocol !== "claude-code" && apiKey === null) throw new Error("no API key for the judge");
7290
7288
  failure = "policy";
7291
7289
  const system = await loadPolicy({ rulesPath: options.rulesPath }, "judge.md");
7292
- const directMessage = findCurrentDirectUserMessage(payload.decisionContext);
7290
+ const directMessage = findCurrentDirectUserMessage(payload.decisionContext?.lastDirectUserMessage);
7293
7291
  const user = buildJudgeMessage({
7294
7292
  rule: evidence.rule,
7295
7293
  basis: evidence.basis,
@@ -7393,7 +7391,6 @@ function buildBudgetOmittedContext(context, field) {
7393
7391
  function buildTaskContext(context) {
7394
7392
  let result = {
7395
7393
  ...context,
7396
- lastDirectUserMessage: context.agentID === null ? context.lastDirectUserMessage : null,
7397
7394
  omittedTaskContext: [...context.omittedTaskContext]
7398
7395
  };
7399
7396
  for (const field of ["originalUserTask", "delegatedTask"]) {
@@ -7431,8 +7428,7 @@ function buildDecisionRequest(payload, policy, configuredRules, lastUserMessage,
7431
7428
  source: "configured",
7432
7429
  text
7433
7430
  };
7434
- const context = payload.decisionContext;
7435
- const isStale = context?.agentID === null && context.lastDirectUserMessage?.freshness === "stale";
7431
+ const isStale = payload.decisionContext?.lastDirectUserMessage?.freshness === "stale";
7436
7432
  const answerGuidance = isStale ? `${BASE_ANSWER_GUIDANCE} ${STALE_ANSWER_GUIDANCE}` : BASE_ANSWER_GUIDANCE;
7437
7433
  const questions = Object.fromEntries(Object.entries(rules).map(([id, rule]) => {
7438
7434
  const canClarifyFalsePositive = rule.source === "shipped" && rule.tier === "hard" && (rule.name === "Policy Tampering" || rule.name === "Audit Tampering");
@@ -7456,7 +7452,7 @@ function buildDecisionRequest(payload, policy, configuredRules, lastUserMessage,
7456
7452
  configuredRules,
7457
7453
  ...repositoryContext === null ? {} : { repositoryContext },
7458
7454
  ...mcpServers.length === 0 ? {} : { mcpServers },
7459
- lastUserMessage: (context === void 0 || context.agentID === null) && !isStale ? lastUserMessage : null,
7455
+ lastUserMessage: isStale ? null : lastUserMessage,
7460
7456
  ...payload.decisionContext === void 0 ? {} : { taskContext: buildTaskContext(payload.decisionContext) },
7461
7457
  action: {
7462
7458
  tool: payload.toolName,
@@ -7856,7 +7852,7 @@ async function classifyWithJev(payload, config, options) {
7856
7852
  key = await resolveApiKey(config.provider, options);
7857
7853
  if (key === null) throw new Error("no API key: set the configured environment variable or key command");
7858
7854
  stage = "evidence";
7859
- const directUserText = findCurrentDirectUserMessage(payload.decisionContext);
7855
+ const directUserText = findCurrentDirectUserMessage(payload.decisionContext?.lastDirectUserMessage);
7860
7856
  const settings = await (options.claudeSettings ?? loadClaudeSettings(config.claudeSettingsPath, options.host));
7861
7857
  const [policy, repositoryContext, mcpServers] = await Promise.all([
7862
7858
  loadPolicy({
@@ -7978,7 +7974,7 @@ async function classifyWithModel(payload, config, options = {}) {
7978
7974
  } catch (error) {
7979
7975
  return buildFailure(config, `policy unreadable: ${toMessage(error)}`);
7980
7976
  }
7981
- const directMessage = findCurrentDirectUserMessage(payload.decisionContext);
7977
+ const directMessage = findCurrentDirectUserMessage(payload.decisionContext?.lastDirectUserMessage);
7982
7978
  const transcript = directMessage === null ? [] : [{
7983
7979
  role: "user",
7984
7980
  text: directMessage
@@ -8506,7 +8502,7 @@ async function classifyAction(request, config, options = {}) {
8506
8502
  ...options,
8507
8503
  host,
8508
8504
  rulesPath: config.rulesPath,
8509
- overturns: config.judgeOverturns ?? "consent"
8505
+ overturns: config.judgeOverturns ?? "any"
8510
8506
  });
8511
8507
  const { review: _review, ...rest } = outcome;
8512
8508
  return {
@@ -8619,10 +8615,11 @@ function parseActionRequest(body) {
8619
8615
  //#endregion
8620
8616
  //#region src/request/render-verdict.ts
8621
8617
  function renderVerdict(verdict) {
8622
- return M(verdict).with({ kind: "allow" }, () => JSON.stringify({ decision: "allow" })).with({ kind: "deny" }, (denied) => JSON.stringify({
8618
+ const rendered = M(verdict).returnType().with({ kind: "allow" }, () => ({ decision: "allow" })).with({ kind: "deny" }, (denied) => ({
8623
8619
  decision: "deny",
8624
8620
  reason: `[${denied.rule}] ${denied.reason}`
8625
8621
  })).exhaustive();
8622
+ return JSON.stringify(rendered);
8626
8623
  }
8627
8624
  //#endregion
8628
8625
  export { resolveConfigPath as A, resolveStateDir as C, DEFAULT_SCOPE_SOURCES as D, DEFAULT_DENIAL_BUDGET as E, string as F, PRESETS as M, number as N, loadConfig as O, strictObject as P, tryClassifyEdit as S, DEFAULT_CONFIG as T, checkContainment as _, loadSessionScope as a, splitCommandWords as b, classifyLocally as c, sendDecision as d, loadRepositoryContext as f, loadPolicy as g, buildDecisionRequest as h, resolveSessionScopePath as i, readHostEnvironment as j, resolveApiKey as k, classifyWithModel as l, DecisionRequestError as m, parseActionRequest as n, EMPTY_SESSION_SCOPE as o, loadCheckoutRemotes as p, classifyAction as r, buildTaskScope as s, renderVerdict as t, parseModelVerdict as u, toRepositorySlug as v, findCheckout as w, loadClaudeSettings as x, splitShellCommand as y };
@@ -0,0 +1,26 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { findCurrentDirectUserMessage } from './find-current-direct-user-message.ts';
3
+
4
+ test('it returns the text of a current direct message', () => {
5
+ expect(findCurrentDirectUserMessage({ text: 'Push it to main', origin: 'composer' })).toBe(
6
+ 'Push it to main',
7
+ );
8
+ });
9
+
10
+ test('it returns nothing for a direct message marked stale', () => {
11
+ expect(
12
+ findCurrentDirectUserMessage({
13
+ text: 'Push it to main',
14
+ origin: 'composer',
15
+ freshness: 'stale',
16
+ }),
17
+ ).toBeNull();
18
+ });
19
+
20
+ test('it returns nothing when no direct message was sent', () => {
21
+ expect(findCurrentDirectUserMessage(null)).toBeNull();
22
+ });
23
+
24
+ test('it returns nothing when the request carries no decision context', () => {
25
+ expect(findCurrentDirectUserMessage(undefined)).toBeNull();
26
+ });
@@ -0,0 +1,9 @@
1
+ import type { DirectUserMessage } from './types.ts';
2
+
3
+ export function findCurrentDirectUserMessage(
4
+ message: Readonly<DirectUserMessage> | null | undefined,
5
+ ): string | null {
6
+ return message === null || message === undefined || message.freshness === 'stale'
7
+ ? null
8
+ : message.text;
9
+ }
@@ -0,0 +1,10 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { isMessageOrigin } from './is-message-origin.ts';
3
+
4
+ test.each(['composer', 'bridge', 'sdk'])('it accepts %s as a direct message origin', (origin) => {
5
+ expect(isMessageOrigin(origin)).toBe(true);
6
+ });
7
+
8
+ test.each(['plugin', 'agent.spawn', ''])('it rejects %p as a direct message origin', (origin) => {
9
+ expect(isMessageOrigin(origin)).toBe(false);
10
+ });
@@ -0,0 +1,6 @@
1
+ import { MESSAGE_ORIGINS } from './message-origins.ts';
2
+ import type { MessageOrigin } from './types.ts';
3
+
4
+ export function isMessageOrigin(value: string): value is MessageOrigin {
5
+ return MESSAGE_ORIGINS.some((origin) => origin === value);
6
+ }
@@ -0,0 +1,3 @@
1
+ // Composer, Remote Control and the SDK are the prompt origins a person types
2
+ // into; any other origin is a delivery from a tool, not direct user evidence.
3
+ export const MESSAGE_ORIGINS = ['composer', 'bridge', 'sdk'] as const;
@@ -0,0 +1,6 @@
1
+ // The commands that can create a worktree, a branch, or a PR, keyed by program.
2
+ // Each entry is the words that follow the program and its leading options.
3
+ export const SCOPE_COMMANDS = {
4
+ git: ['worktree add', 'checkout', 'switch', 'branch'],
5
+ gh: ['pr create'],
6
+ } as const;
@@ -0,0 +1,51 @@
1
+ import type { MESSAGE_ORIGINS } from './message-origins.ts';
2
+
3
+ export type MessageOrigin = (typeof MESSAGE_ORIGINS)[number];
4
+
5
+ export interface UserTask {
6
+ readonly text: string;
7
+ readonly origin: MessageOrigin;
8
+ }
9
+
10
+ export interface DirectUserMessage extends UserTask {
11
+ readonly freshness?: 'stale' | undefined;
12
+ }
13
+
14
+ export interface DelegatedTask {
15
+ readonly text: string;
16
+ readonly origin: 'agent.spawn';
17
+ }
18
+
19
+ export interface TaskOmission {
20
+ readonly field: 'originalUserTask' | 'delegatedTask';
21
+ readonly reason: 'unavailable' | 'budget';
22
+ }
23
+
24
+ export interface ModContext {
25
+ readonly agentID: string | null;
26
+ readonly originalUserTask: UserTask | null;
27
+ readonly delegatedTask: DelegatedTask | null;
28
+ readonly lastDirectUserMessage: DirectUserMessage | null;
29
+ readonly omittedTaskContext: TaskOmission[];
30
+ }
31
+
32
+ export interface ModRequest {
33
+ readonly sessionID: string;
34
+ readonly toolUseID?: string | undefined;
35
+ readonly cwd: string;
36
+ readonly toolName: string;
37
+ readonly toolInput: Readonly<Record<string, unknown>>;
38
+ readonly context: ModContext;
39
+ }
40
+
41
+ export interface ModScopeRecord {
42
+ readonly sessionID: string;
43
+ readonly cwd: string;
44
+ readonly startedAt: number;
45
+ readonly command: string;
46
+ readonly resultText: string;
47
+ }
48
+
49
+ export type ModVerdict =
50
+ | { readonly decision: 'allow' }
51
+ | { readonly decision: 'deny'; readonly reason: string };
@@ -0,0 +1,46 @@
1
+ import type { ModRequest, TaskOmission } from '../contract/types.ts';
2
+ import type { PromptContext } from './types.ts';
3
+
4
+ interface ActionRequestInput {
5
+ readonly sessionID: string;
6
+ readonly toolUseID: string | undefined;
7
+ readonly cwd: string;
8
+ readonly toolName: string;
9
+ readonly toolInput: Readonly<Record<string, unknown>>;
10
+ readonly agentID: string | null;
11
+ readonly delegatedText: string | undefined;
12
+ readonly prompts: PromptContext;
13
+ }
14
+
15
+ export function buildActionRequest(input: Readonly<ActionRequestInput>): ModRequest {
16
+ const isChild = input.agentID !== null;
17
+
18
+ const delegatedTask =
19
+ input.delegatedText === undefined
20
+ ? null
21
+ : { text: input.delegatedText, origin: 'agent.spawn' as const };
22
+
23
+ const omittedTaskContext: TaskOmission[] = [
24
+ ...(input.prompts.originalUserTask === null
25
+ ? [{ field: 'originalUserTask' as const, reason: 'unavailable' as const }]
26
+ : []),
27
+ ...(isChild && delegatedTask === null
28
+ ? [{ field: 'delegatedTask' as const, reason: 'unavailable' as const }]
29
+ : []),
30
+ ];
31
+
32
+ return {
33
+ sessionID: input.sessionID,
34
+ ...(input.toolUseID === undefined ? {} : { toolUseID: input.toolUseID }),
35
+ cwd: input.cwd,
36
+ toolName: input.toolName,
37
+ toolInput: input.toolInput,
38
+ context: {
39
+ agentID: input.agentID,
40
+ originalUserTask: input.prompts.originalUserTask,
41
+ delegatedTask,
42
+ lastDirectUserMessage: input.prompts.lastDirectUserMessage,
43
+ omittedTaskContext,
44
+ },
45
+ };
46
+ }
@@ -1,4 +1,6 @@
1
- import type { DirectUserMessage, PromptContext, UserTask } from './types.ts';
1
+ import { isMessageOrigin } from '../contract/is-message-origin.ts';
2
+ import type { DirectUserMessage, UserTask } from '../contract/types.ts';
3
+ import type { PromptContext } from './types.ts';
2
4
 
3
5
  export function buildPromptContext(
4
6
  previous: PromptContext | null,
@@ -20,7 +22,7 @@ export function buildPromptContext(
20
22
 
21
23
  const origin = input.origin.kind;
22
24
 
23
- if (origin !== 'composer' && origin !== 'bridge' && origin !== 'sdk') {
25
+ if (!isMessageOrigin(origin)) {
24
26
  return {
25
27
  originalUserTask: previous?.originalUserTask ?? null,
26
28
  lastDirectUserMessage: buildStaleMessage(previous?.lastDirectUserMessage ?? null),
@@ -0,0 +1,18 @@
1
+ import { SCOPE_COMMANDS } from '../contract/scope-commands.ts';
2
+
3
+ // A cheap gate before the record subprocess: options may sit between the
4
+ // program and its subcommand, and the CLI parses the words exactly.
5
+ const SCOPE_COMMAND_PATTERN = new RegExp(
6
+ Object.entries(SCOPE_COMMANDS)
7
+ .map(([program, commands]) => {
8
+ const alternatives = commands.map((command) => command.split(' ').join(String.raw`\s+`));
9
+
10
+ return String.raw`\b${program}\s[^\n]*?\b(?:${alternatives.join('|')})\b`;
11
+ })
12
+ .join('|'),
13
+ 'u',
14
+ );
15
+
16
+ export function isScopeCommand(command: string): boolean {
17
+ return SCOPE_COMMAND_PATTERN.test(command);
18
+ }
@@ -0,0 +1,3 @@
1
+ export function isToolInput(value: unknown): value is Readonly<Record<string, unknown>> {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
@@ -1,11 +1,8 @@
1
- interface Decision {
2
- readonly decision: 'allow' | 'deny';
3
- readonly reason?: string;
4
- }
1
+ import type { ModVerdict } from '../contract/types.ts';
5
2
 
6
3
  // The CLI ships with this mod, so any other shape is a mismatched or broken CLI,
7
4
  // and only an exact verdict may replace the prompt.
8
- export function parseDecision(stdout: string): Decision | null {
5
+ export function parseDecision(stdout: string): ModVerdict | null {
9
6
  let body: unknown;
10
7
 
11
8
  try {
@@ -1,12 +1,11 @@
1
+ import type { ModScopeRecord } from '../contract/types.ts';
2
+ import { buildActionRequest } from './build-action-request.ts';
1
3
  import { buildPromptContext } from './build-prompt-context.ts';
4
+ import { isScopeCommand } from './is-scope-command.ts';
5
+ import { isToolInput } from './is-tool-input.ts';
2
6
  import { parseDecision } from './parse-decision.ts';
3
7
  import type { ModOn, ModOptions, PromptContext } from './types.ts';
4
8
 
5
- // Commands that can create a worktree, a branch, or a PR. Any other call skips
6
- // the record subprocess, so ordinary calls pay nothing for the session scope.
7
- const SCOPE_COMMAND =
8
- /\bgit\s[^\n]*?\b(?:worktree\s+add|checkout|switch|branch)\b|\bgh\s+pr\s+create\b/u;
9
-
10
9
  // The record may look up a created PR's head branch with gh, which the CLI
11
10
  // bounds at 5 seconds.
12
11
  const RECORD_TIMEOUT_MS = 8000;
@@ -73,7 +72,7 @@ export function register(on: ModOn, options: ModOptions): void {
73
72
  activeCalls.set(e.tool_use_id, e.agentId ?? null);
74
73
 
75
74
  const command =
76
- e.tool === 'Bash' && typeof e.command === 'string' && SCOPE_COMMAND.test(e.command)
75
+ e.tool === 'Bash' && typeof e.command === 'string' && isScopeCommand(e.command)
77
76
  ? e.command
78
77
  : null;
79
78
 
@@ -89,7 +88,14 @@ export function register(on: ModOn, options: ModOptions): void {
89
88
 
90
89
  if (command !== null && cwd !== null && sessionID !== null && result.deny === undefined) {
91
90
  const executable = typeof options.command === 'string' ? options.command : 'auto-mode';
92
- const request = { sessionID, cwd, startedAt, command, resultText: result.text ?? '' };
91
+
92
+ const request: ModScopeRecord = {
93
+ sessionID,
94
+ cwd,
95
+ startedAt,
96
+ command,
97
+ resultText: result.text ?? '',
98
+ };
93
99
 
94
100
  try {
95
101
  await $.process.run([executable, 'record'], {
@@ -137,26 +143,17 @@ export function register(on: ModOn, options: ModOptions): void {
137
143
 
138
144
  const agentID = e.tool_use_id === undefined ? null : activeCalls.get(e.tool_use_id);
139
145
 
140
- if (e.tool_use_id !== undefined && agentID === undefined) {
146
+ if (agentID === undefined) {
141
147
  $.ui.log(`${logPrefix} evaluation skipped; untracked tool call`, { to: 'debug' });
142
148
 
143
149
  return decided;
144
150
  }
145
151
 
146
- const isChild = agentID !== null && agentID !== undefined;
147
- const delegatedText = isChild ? delegatedTasks.get(agentID) : undefined;
152
+ if (!isToolInput(e.input)) {
153
+ $.ui.log(`${logPrefix} evaluation skipped; tool input is not an object`, { to: 'debug' });
148
154
 
149
- const delegatedTask =
150
- delegatedText === undefined ? null : { text: delegatedText, origin: 'agent.spawn' };
151
-
152
- const omittedTaskContext = [
153
- ...(prompts.originalUserTask === null
154
- ? [{ field: 'originalUserTask', reason: 'unavailable' }]
155
- : []),
156
- ...(isChild && delegatedTask === null
157
- ? [{ field: 'delegatedTask', reason: 'unavailable' }]
158
- : []),
159
- ];
155
+ return decided;
156
+ }
160
157
 
161
158
  const command = typeof options.command === 'string' ? options.command : 'auto-mode';
162
159
  const childTimeoutMs = EVALUATION_TIMEOUT_MS;
@@ -164,20 +161,16 @@ export function register(on: ModOn, options: ModOptions): void {
164
161
 
165
162
  $.ui.log(`${logPrefix} evaluator invoked`, { to: 'debug' });
166
163
 
167
- const request = {
164
+ const request = buildActionRequest({
168
165
  sessionID,
169
- ...(e.tool_use_id === undefined ? {} : { toolUseID: e.tool_use_id }),
166
+ toolUseID: e.tool_use_id,
170
167
  cwd,
171
168
  toolName: e.tool,
172
169
  toolInput: e.input,
173
- context: {
174
- agentID: agentID ?? null,
175
- originalUserTask: prompts.originalUserTask,
176
- delegatedTask,
177
- lastDirectUserMessage: isChild ? null : prompts.lastDirectUserMessage,
178
- omittedTaskContext,
179
- },
180
- };
170
+ agentID,
171
+ delegatedText: agentID === null ? undefined : delegatedTasks.get(agentID),
172
+ prompts,
173
+ });
181
174
 
182
175
  const result = await $.process.run(
183
176
  [command, 'run', '--jev-only', '--evaluation-deadline', String(deadlineAt)],
@@ -1,3 +1,5 @@
1
+ import type { DirectUserMessage, UserTask } from '../contract/types.ts';
2
+
1
3
  export interface PermissionDecision {
2
4
  readonly decision: 'allow' | 'ask' | 'deny';
3
5
  readonly reason?: string;
@@ -45,15 +47,6 @@ export interface CallResult {
45
47
  readonly isError?: true;
46
48
  }
47
49
 
48
- export interface UserTask {
49
- readonly text: string;
50
- readonly origin: 'composer' | 'bridge' | 'sdk';
51
- }
52
-
53
- export interface DirectUserMessage extends UserTask {
54
- readonly freshness?: 'stale';
55
- }
56
-
57
50
  export interface PromptContext {
58
51
  readonly originalUserTask: UserTask | null;
59
52
  readonly lastDirectUserMessage: DirectUserMessage | null;
@@ -8,5 +8,6 @@
8
8
  "noEmit": true,
9
9
  "skipLibCheck": true
10
10
  },
11
- "include": ["hooks/**/*.ts"]
11
+ "include": ["hooks/**/*.ts", "contract/**/*.ts"],
12
+ "exclude": ["contract/**/*.test.ts"]
12
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-mode",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",
@@ -44,8 +44,7 @@
44
44
  "audit": "bun audit",
45
45
  "bench": "bun --no-env-file src/secrets/find-secret.bench.ts",
46
46
  "build": "tsdown",
47
- "build:agents": "bash scripts/build-agents-md.sh agents/shared.md agents/project.md AGENTS.md",
48
- "check": "bun run format:check && bun run lint && bun run check:imports && bun run typecheck && bun run deadcode && bun run test && bun run build && bun run check:artifact && bun run publint",
47
+ "check": "bun run format:check && bun run lint:full && bun run check:imports && bun run typecheck && bun run deadcode && bun run test && bun run build && bun run check:artifact && bun run publint",
49
48
  "deadcode": "knip",
50
49
  "eval": "bun --no-env-file --conditions=auto-mode-eval evals/eval.ts",
51
50
  "eval:answer-guidance": "bun --no-env-file --conditions=auto-mode-eval evals/runners/run-answer-guidance-evaluation.ts",
@@ -58,8 +57,9 @@
58
57
  "first-publish": "scripts/first-publish.sh",
59
58
  "format": "oxfmt . && format-codemod --quiet .",
60
59
  "format:check": "oxfmt --check . && format-codemod --check --quiet .",
61
- "lint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error",
62
- "lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
60
+ "lint": "oxlint",
61
+ "lint:fix": "oxlint --fix",
62
+ "lint:full": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error",
63
63
  "prepare": "bash scripts/link-evals-package.sh && (command -v git > /dev/null 2>&1 && lefthook install --force || true)",
64
64
  "publint": "publint",
65
65
  "test": "bun run build && bun --no-env-file test --conditions=auto-mode-eval",
@@ -80,7 +80,7 @@
80
80
  "@types/node": "26.5.1",
81
81
  "@zgeoff/bun-test-extended": "0.1.0",
82
82
  "@zgeoff/format-codemod": "0.1.0",
83
- "@zgeoff/oxlint-config": "0.1.0",
83
+ "@zgeoff/oxlint-config": "0.2.0",
84
84
  "knip": "6.35.1",
85
85
  "lefthook": "2.1.12",
86
86
  "mitata": "1.0.34",
package/policy/rules.md CHANGED
@@ -156,17 +156,25 @@ registry, or a deploy command.
156
156
  **Must name:** publish, release, or deploy, plus the target.
157
157
 
158
158
  ### Outbound Communication
159
- A message that reaches people outside the checkout's own repositories and outside
160
- the channels the user named: an email, a chat message to a person or channel the
161
- user did not name, a post on a public service, or an issue, comment, pull request,
162
- or review on a repository that is not a remote of the checkout.
159
+ A message that reaches people the user did not ask to reach: an email, a chat
160
+ message to a person or channel the user did not name, a post on a public service,
161
+ an issue, comment, pull request, or review on a repository that is not a remote of
162
+ the checkout, or a comment or review on an issue or pull request the task does not
163
+ own.
163
164
 
164
165
  **Must name:** that the message is sent, and to whom or where.
165
166
 
166
- An issue, comment, pull request, or review on a repository that is a remote of
167
- the checkout is not this rule, whichever issue or pull request it targets. Compare
168
- the repository's host, owner, and name with the remotes; a lookalike name is not a
169
- match.
167
+ The task's own threads on a repository that is a remote of the checkout are not
168
+ this rule: opening the pull request or issue the task calls for, and commenting on
169
+ or reviewing a pull request in the task scope or an issue or pull request the user
170
+ named. Compare the repository's host, owner, and name with the remotes; a
171
+ lookalike name is not a match.
172
+
173
+ A comment or review on any other issue or pull request is this rule, even on a
174
+ remote of the checkout, because it reaches that thread's author and readers. A
175
+ number alone, such as `gh pr comment 498`, does not show that the pull request is
176
+ the task's own. Without a task scope fact that lists it or a user message that
177
+ names it, treat it as a thread the task does not own.
170
178
 
171
179
  Once the user names a channel or an outside thread, further posts to that same
172
180
  place stand for the rest of the session. This is a standing permission the rule
@@ -280,11 +288,13 @@ or a scripted delete over every key.
280
288
  **Must name:** the table or the database, and the operation.
281
289
 
282
290
  ### Persistent Configuration
283
- Granting access into this environment from outside it, or redirecting where
284
- credentials or code go: adding a key to `~/.ssh/authorized_keys`, changing
285
- `~/.ssh/config`, writing package registry credentials or pointing a registry at a
286
- new host, and the global git settings `credential.helper`, `url.<base>.insteadOf`,
287
- and `core.sshCommand`.
291
+ Granting access into this environment from outside it, redirecting where
292
+ credentials or code go, or opening credentials to other users: adding a key to
293
+ `~/.ssh/authorized_keys`, changing `~/.ssh/config`, writing package registry
294
+ credentials or pointing a registry at a new host, the global git settings
295
+ `credential.helper`, `url.<base>.insteadOf`, and `core.sshCommand`, and widening
296
+ the permissions of a credential file or directory, such as `chmod -R a+rwx ~/.ssh`
297
+ or making a private key or `~/.aws/credentials` readable by group or others.
288
298
 
289
299
  **Must name:** the file or the setting.
290
300
 
@@ -294,10 +304,12 @@ grant, not as a file edit.
294
304
  The environment is isolated, so a change that stays inside it is not this rule:
295
305
  `crontab`, a systemd unit, a launch agent, a shell startup file, `PATH`, other
296
306
  `git config --global` keys, a detached process, `sudo`, a sudoers entry, and
297
- `chmod` or `chown`. Using that access against auto-mode is Policy Tampering.
307
+ `chmod` or `chown` on anything that is not a credential. Using that access against
308
+ auto-mode is Policy Tampering.
298
309
 
299
310
  Authenticating a tool the user asked to use, such as `npm login` before a publish
300
- the user asked for, is not this rule. Neither is a change that narrows access.
311
+ the user asked for, is not this rule. Neither is a change that narrows access,
312
+ such as `chmod 600` on a private key.
301
313
 
302
314
  ### Credential Exploration
303
315
  Printing a credential's value into the transcript, or searching broadly for keys