@craftsamo/agent-github 0.1.0 → 0.2.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/opencode.js CHANGED
@@ -4,6 +4,13 @@ var __export = (target, all) => {
4
4
  __defProp(target, name, { get: all[name], enumerable: true });
5
5
  };
6
6
 
7
+ // src/entry/package-root.ts
8
+ import { dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ function packageRoot(bundleUrl) {
11
+ return dirname(dirname(fileURLToPath(bundleUrl)));
12
+ }
13
+
7
14
  // node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/classic/external.js
8
15
  var external_exports = {};
9
16
  __export(external_exports, {
@@ -20204,13 +20211,13 @@ async function ensureLabel(repo, name, ctx) {
20204
20211
  function isTypeFailure(error62) {
20205
20212
  return isToolError(error62) && TYPE_FAILURE_CODES.includes(error62.code) && TYPE_MENTION.test(error62.message);
20206
20213
  }
20207
- async function withKindFallback(kind, placement, attempt) {
20214
+ async function withKindFallback(kind, placement, attempt2) {
20208
20215
  try {
20209
- return { placement, result: await attempt(placement) };
20216
+ return { placement, result: await attempt2(placement) };
20210
20217
  } catch (error62) {
20211
20218
  if (placement.via !== "type" || !isTypeFailure(error62)) throw error62;
20212
20219
  const fallback = labelKind(kind);
20213
- return { placement: fallback, result: await attempt(fallback) };
20220
+ return { placement: fallback, result: await attempt2(fallback) };
20214
20221
  }
20215
20222
  }
20216
20223
  async function kindFlags(placement, repo, ctx) {
@@ -20512,6 +20519,8 @@ async function readSubIssues(parent, ctx) {
20512
20519
  }
20513
20520
 
20514
20521
  // src/core/refs.ts
20522
+ import { realpath } from "node:fs/promises";
20523
+ import { isAbsolute, relative, sep } from "node:path";
20515
20524
  var DEFAULT_HOST = "github.com";
20516
20525
  var SSH_ALIAS_HOST = "ssh.github.com";
20517
20526
  var GIT_CONFIG_ENV = ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM"];
@@ -20595,11 +20604,25 @@ function gitEnv() {
20595
20604
  }
20596
20605
  return { ...env, GIT_TERMINAL_PROMPT: "0" };
20597
20606
  }
20598
- async function repoFromOrigin(cwd, signal) {
20607
+ async function isWithin(dir, root) {
20608
+ try {
20609
+ const path = relative(await realpath(root), await realpath(dir));
20610
+ return path === "" || !(path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path));
20611
+ } catch {
20612
+ return false;
20613
+ }
20614
+ }
20615
+ async function repoFromOrigin(cwd, signal, ownDir) {
20599
20616
  const env = gitEnv();
20600
- const unresolved = (why) => invalid(
20601
- `Cannot tell which repository to use: ${why}. Run the call inside a clone of a GitHub repository, or pass \`repo\` as owner/name.`
20617
+ const unresolved = (why, advice = "") => invalid(
20618
+ `Cannot tell which repository to use: ${why}. Run the call inside a clone of a GitHub repository, or pass \`repo\` as owner/name.${advice}`
20602
20619
  );
20620
+ if (ownDir !== void 0 && await isWithin(cwd, ownDir)) {
20621
+ throw unresolved(
20622
+ `${cwd} is where these tools are installed, not a project`,
20623
+ " An issue can be given as `owner/repo#N`."
20624
+ );
20625
+ }
20603
20626
  let result;
20604
20627
  try {
20605
20628
  result = await exec(["git", "remote", "get-url", "origin"], { cwd, env, signal });
@@ -20614,7 +20637,10 @@ async function repoFromOrigin(cwd, signal) {
20614
20637
  return repo;
20615
20638
  }
20616
20639
  async function resolveRepo(repo, ctx) {
20617
- return repo === void 0 || repo.trim() === "" ? repoFromOrigin(ctx.cwd, ctx.signal) : parseRepo(repo);
20640
+ return repo === void 0 || repo.trim() === "" ? repoFromOrigin(ctx.cwd, ctx.signal, ctx.ownDir) : parseRepo(repo);
20641
+ }
20642
+ function isBareNumber(ref) {
20643
+ return BARE_NUMBER_PATTERN.test(ref.trim());
20618
20644
  }
20619
20645
  function parseNumber(digits, ref) {
20620
20646
  const number4 = Number.parseInt(digits, 10);
@@ -20665,10 +20691,9 @@ async function parseNumberRef(ref, repoArg, ctx, options = {}) {
20665
20691
  }
20666
20692
 
20667
20693
  // src/core/github/issue-refs.ts
20668
- var BARE_NUMBER = /^#?\d+$/;
20669
20694
  async function resolveRelatedIssue(ref, target, ctx) {
20670
20695
  const text = ref.trim();
20671
- return parseNumberRef(text, BARE_NUMBER.test(text) ? target.full : void 0, ctx, { accept: "issue" });
20696
+ return parseNumberRef(text, isBareNumber(text) ? target.full : void 0, ctx, { accept: "issue" });
20672
20697
  }
20673
20698
  function issueLabel(issue2) {
20674
20699
  return `${issue2.repo.full}#${issue2.number}`;
@@ -20696,6 +20721,10 @@ function defineTool(spec) {
20696
20721
  if (!(spec.output instanceof external_exports.ZodObject)) {
20697
20722
  throw new Error(`Tool "${spec.id}" must have an object output schema, since a result is an object.`);
20698
20723
  }
20724
+ const unknownTarget = Object.keys(spec.targets ?? {}).find((name) => !Object.hasOwn(spec.input.shape, name));
20725
+ if (unknownTarget !== void 0) {
20726
+ throw new Error(`Tool "${spec.id}" declares the target "${unknownTarget}", which is not one of its arguments.`);
20727
+ }
20699
20728
  return spec;
20700
20729
  }
20701
20730
  function knownIds(setting, ids, known) {
@@ -20788,6 +20817,7 @@ var StepErrors = class {
20788
20817
  // src/core/issues/write-support.ts
20789
20818
  var REPO_DESCRIPTION = "owner/name; defaults to the working tree's repository (its origin). Used for a bare number.";
20790
20819
  var ISSUE_DESCRIPTION = "The issue, as 12, #12, owner/repo#12 or its URL.";
20820
+ var ISSUE_TARGETS = { issue: "issue", repo: "repo" };
20791
20821
  var LIST_SYNTAX = /[,"\r\n]/;
20792
20822
  var URL_LINE = /^https?:\/\/\S+$/;
20793
20823
  function invalid2(message) {
@@ -20857,6 +20887,7 @@ var issueClose = defineTool({
20857
20887
  ].join(" "),
20858
20888
  toolset: "issues",
20859
20889
  class: "overwrite",
20890
+ targets: ISSUE_TARGETS,
20860
20891
  input: input2,
20861
20892
  output: output2,
20862
20893
  async execute(args, ctx) {
@@ -20913,6 +20944,7 @@ var issueComment = defineTool({
20913
20944
  ].join(" "),
20914
20945
  toolset: "issues",
20915
20946
  class: "add",
20947
+ targets: ISSUE_TARGETS,
20916
20948
  input: input3,
20917
20949
  output: output3,
20918
20950
  async execute(args, ctx) {
@@ -21030,6 +21062,7 @@ var issueCreate = defineTool({
21030
21062
  ].join(" "),
21031
21063
  toolset: "issues",
21032
21064
  class: "add",
21065
+ targets: { repo: "repo", parent: "issue", blocked_by: "issues" },
21033
21066
  input: input4,
21034
21067
  output: output4,
21035
21068
  async execute(args, ctx) {
@@ -21177,6 +21210,7 @@ var issueDevelop = defineTool({
21177
21210
  ].join(" "),
21178
21211
  toolset: "issues",
21179
21212
  class: "idempotent",
21213
+ targets: { ...ISSUE_TARGETS, branch_repo: "repo" },
21180
21214
  input: input5,
21181
21215
  output: output5,
21182
21216
  async execute(args, ctx) {
@@ -21324,6 +21358,7 @@ var issueEdit = defineTool({
21324
21358
  ].join(" "),
21325
21359
  toolset: "issues",
21326
21360
  class: "overwrite",
21361
+ targets: ISSUE_TARGETS,
21327
21362
  input: input6,
21328
21363
  output: output6,
21329
21364
  async execute(args, ctx) {
@@ -21348,14 +21383,14 @@ var issueEdit = defineTool({
21348
21383
  if (change.removeMilestone) flags.push("--remove-milestone");
21349
21384
  const named = new Set([...change.addLabels, ...change.removeLabels].map((label) => label.toLowerCase()));
21350
21385
  const swapped = (where) => where.via === "label" ? otherKindLabels(current?.labels ?? [], where.name).filter((label) => !named.has(label.toLowerCase())).flatMap((label) => ["--remove-label", label]) : [];
21351
- const attempt = async (where) => editIssue(
21386
+ const attempt2 = async (where) => editIssue(
21352
21387
  target,
21353
21388
  issue2.number,
21354
21389
  where ? [...flags, ...await kindFlags(where, target, ctx), ...swapped(where)] : flags,
21355
21390
  ctx,
21356
21391
  body
21357
21392
  );
21358
- const applied = kind ? await withKindFallback(kind.name, kind.placement, attempt) : { placement: void 0, result: await attempt() };
21393
+ const applied = kind ? await withKindFallback(kind.name, kind.placement, attempt2) : { placement: void 0, result: await attempt2() };
21359
21394
  return {
21360
21395
  repo: target.full,
21361
21396
  issue: issue2.number,
@@ -21682,6 +21717,16 @@ var issueRelate = defineTool({
21682
21717
  ].join(" "),
21683
21718
  toolset: "issues",
21684
21719
  class: "idempotent",
21720
+ targets: {
21721
+ ...ISSUE_TARGETS,
21722
+ parent: "issue",
21723
+ add_sub_issues: "issues",
21724
+ remove_sub_issues: "issues",
21725
+ add_blocked_by: "issues",
21726
+ remove_blocked_by: "issues",
21727
+ add_blocking: "issues",
21728
+ remove_blocking: "issues"
21729
+ },
21685
21730
  input: input8,
21686
21731
  output: output8,
21687
21732
  async execute(args, ctx) {
@@ -21780,6 +21825,7 @@ var issueReopen = defineTool({
21780
21825
  ].join(" "),
21781
21826
  toolset: "issues",
21782
21827
  class: "overwrite",
21828
+ targets: ISSUE_TARGETS,
21783
21829
  input: input9,
21784
21830
  output: output9,
21785
21831
  async execute(args, ctx) {
@@ -21810,18 +21856,71 @@ var ALL_TOOLS = [
21810
21856
  issueDevelop
21811
21857
  ];
21812
21858
 
21813
- // src/entry/permission.ts
21814
- var ANY_RESOURCE = "*";
21815
- async function permissionResource(args, ctx) {
21816
- const repo = typeof args === "object" && args !== null ? args.repo : void 0;
21817
- if (repo !== void 0 && typeof repo !== "string") return ANY_RESOURCE;
21859
+ // src/core/targets.ts
21860
+ var ANY_TARGET = "*";
21861
+ var ISSUE_ARGUMENT = "issue";
21862
+ async function attempt(fn) {
21818
21863
  try {
21819
- return (await resolveRepo(repo, ctx)).full.toLowerCase();
21864
+ return await fn();
21820
21865
  } catch (error62) {
21821
21866
  if (isAbortError(error62)) throw error62;
21822
- return ANY_RESOURCE;
21867
+ return void 0;
21823
21868
  }
21824
21869
  }
21870
+ function isBlank(value) {
21871
+ return typeof value === "string" && value.trim() === "";
21872
+ }
21873
+ function ownRepository(spec, args, ctx) {
21874
+ const repo = args.repo;
21875
+ const issue2 = args[ISSUE_ARGUMENT];
21876
+ let pending;
21877
+ async function resolve2() {
21878
+ if (spec.targets?.[ISSUE_ARGUMENT] === "issue" && typeof issue2 === "string" && !isBareNumber(issue2)) {
21879
+ const named = await attempt(() => parseNumberRef(issue2, void 0, ctx, { accept: "issue" }));
21880
+ if (named) return named.repo;
21881
+ }
21882
+ if (repo !== void 0 && typeof repo !== "string") return void 0;
21883
+ return attempt(() => resolveRepo(repo, ctx));
21884
+ }
21885
+ return () => pending ??= resolve2();
21886
+ }
21887
+ async function issueRepository(value, own2, ctx) {
21888
+ if (typeof value !== "string") return void 0;
21889
+ const bare = isBareNumber(value);
21890
+ const repoArg = bare ? (await own2())?.full : void 0;
21891
+ if (bare && repoArg === void 0) return void 0;
21892
+ return attempt(async () => (await parseNumberRef(value, repoArg, ctx, { accept: "issue" })).repo);
21893
+ }
21894
+ async function targetRepository(kind, value, own2, ctx) {
21895
+ if (kind !== "repo") return issueRepository(value, own2, ctx);
21896
+ return typeof value === "string" ? attempt(() => parseRepo(value)) : void 0;
21897
+ }
21898
+ async function writeTargets(spec, args, ctx) {
21899
+ const given3 = typeof args === "object" && args !== null ? args : {};
21900
+ const own2 = ownRepository(spec, given3, ctx);
21901
+ const found = /* @__PURE__ */ new Set();
21902
+ const add = (repo) => found.add(repo ? repo.full.toLowerCase() : ANY_TARGET);
21903
+ add(await own2());
21904
+ for (const [name, kind] of Object.entries(spec.targets ?? {})) {
21905
+ const value = given3[name];
21906
+ if (value === void 0) continue;
21907
+ if (kind === "issues" && !Array.isArray(value)) {
21908
+ add(void 0);
21909
+ continue;
21910
+ }
21911
+ for (const item of kind === "issues" ? value : [value]) {
21912
+ if (!isBlank(item)) add(await targetRepository(kind, item, own2, ctx));
21913
+ }
21914
+ }
21915
+ const repos = [...found].filter((repo) => repo !== ANY_TARGET).sort();
21916
+ return found.has(ANY_TARGET) ? [...repos, ANY_TARGET] : repos;
21917
+ }
21918
+
21919
+ // src/entry/permission.ts
21920
+ var ANY_RESOURCE = ANY_TARGET;
21921
+ function permissionResources(spec, args, ctx) {
21922
+ return writeTargets(spec, args, ctx);
21923
+ }
21825
21924
 
21826
21925
  // src/entry/settings.ts
21827
21926
  var ALL_TOOLSETS = "all";
@@ -21910,11 +22009,14 @@ function settingsFromOptions(options, env) {
21910
22009
  }
21911
22010
 
21912
22011
  // src/opencode/shared.ts
21913
- function permissionMessage(spec, resource) {
21914
- return resource === ANY_RESOURCE ? spec.title : `${spec.title} on ${resource}`;
22012
+ function permissionMessage(spec, resources) {
22013
+ return resources.length === 1 && resources[0] === ANY_RESOURCE ? spec.title : `${spec.title} on ${resources.join(", ")}`;
22014
+ }
22015
+ function persistableResources(resources) {
22016
+ return resources.filter((resource) => resource !== ANY_RESOURCE);
21915
22017
  }
21916
- function permissionMetadata(spec, args, resource) {
21917
- return { arguments: args, class: spec.class, message: permissionMessage(spec, resource) };
22018
+ function permissionMetadata(spec, args, resources) {
22019
+ return { arguments: args, class: spec.class, message: permissionMessage(spec, resources) };
21918
22020
  }
21919
22021
  function needsPermission(spec) {
21920
22022
  return spec.class !== "read";
@@ -21952,19 +22054,19 @@ ${lines.join("\n")}` : "";
21952
22054
  function workingDir(context) {
21953
22055
  return context.worktree && context.worktree !== "/" ? context.worktree : context.directory;
21954
22056
  }
21955
- function v1Tool(spec) {
22057
+ function v1Tool(spec, ownDir) {
21956
22058
  return {
21957
22059
  description: `${spec.description}${describeArguments(spec)}`,
21958
22060
  args: spec.input.shape,
21959
22061
  async execute(args, context) {
21960
- const ctx = { cwd: workingDir(context), signal: context.abort };
22062
+ const ctx = { cwd: workingDir(context), ownDir, signal: context.abort };
21961
22063
  if (needsPermission(spec)) {
21962
- const resource = await permissionResource(args, ctx);
22064
+ const resources = await permissionResources(spec, args, ctx);
21963
22065
  await context.ask({
21964
22066
  permission: spec.id,
21965
- patterns: [resource],
21966
- always: [resource],
21967
- metadata: permissionMetadata(spec, args, resource)
22067
+ patterns: resources,
22068
+ always: persistableResources(resources),
22069
+ metadata: permissionMetadata(spec, args, resources)
21968
22070
  });
21969
22071
  }
21970
22072
  const result = await runTool(spec, args, ctx);
@@ -21975,7 +22077,7 @@ function v1Tool(spec) {
21975
22077
  function createV1Server(specs, deps = {}) {
21976
22078
  return async function server(_input, options) {
21977
22079
  const tool = {};
21978
- for (const spec of selectedTools(specs, options, deps.env)) tool[spec.id] = v1Tool(spec);
22080
+ for (const spec of selectedTools(specs, options, deps.env)) tool[spec.id] = v1Tool(spec, deps.ownDir);
21979
22081
  return { tool };
21980
22082
  };
21981
22083
  }
@@ -21983,7 +22085,7 @@ function createV1Server(specs, deps = {}) {
21983
22085
  // src/opencode/skills.ts
21984
22086
  import { readFile, readdir } from "node:fs/promises";
21985
22087
  import { join, resolve } from "node:path";
21986
- import { fileURLToPath } from "node:url";
22088
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
21987
22089
  var SKILL_FILE = "SKILL.md";
21988
22090
  var FRONTMATTER = /^---[ \t]*\r?\n([\s\S]*?\r?\n)?---[ \t]*(?:\r?\n|$)/;
21989
22091
  var KEY_LINE = /^(name|description)[ \t]*:[ \t]*(.*?)[ \t]*$/;
@@ -22043,7 +22145,7 @@ async function readSkillFile(path) {
22043
22145
  }
22044
22146
  }
22045
22147
  async function loadSkills(dir) {
22046
- const root = resolve(typeof dir === "string" ? dir : fileURLToPath(dir));
22148
+ const root = resolve(typeof dir === "string" ? dir : fileURLToPath2(dir));
22047
22149
  let entries;
22048
22150
  try {
22049
22151
  entries = (await readdir(root, { withFileTypes: true })).filter((entry) => !entry.isFile()).map((entry) => entry.name);
@@ -22070,7 +22172,7 @@ async function sessionDirectory(ctx, sessionID) {
22070
22172
  return void 0;
22071
22173
  }
22072
22174
  }
22073
- function v2Tool(ctx, spec) {
22175
+ function v2Tool(ctx, spec, ownDir) {
22074
22176
  return {
22075
22177
  name: spec.id,
22076
22178
  description: spec.description,
@@ -22084,23 +22186,23 @@ function v2Tool(ctx, spec) {
22084
22186
  `invalid_argument: The session's directory could not be read, so ${spec.id} cannot tell which repository it changes; pass \`repo\` as owner/name or retry.`
22085
22187
  );
22086
22188
  }
22087
- const resource = await permissionResource(input10, { cwd: directory, signal });
22189
+ const resources = await permissionResources(spec, input10, { cwd: directory, ownDir, signal });
22088
22190
  await ctx.permission.assert({
22089
22191
  action: spec.id,
22090
- resources: [resource],
22091
- save: [resource],
22092
- metadata: permissionMetadata(spec, input10, resource),
22192
+ resources,
22193
+ save: persistableResources(resources),
22194
+ metadata: permissionMetadata(spec, input10, resources),
22093
22195
  sessionID
22094
22196
  });
22095
22197
  }
22096
- const result = await runTool(spec, input10, { cwd: directory ?? process.cwd(), signal });
22198
+ const result = await runTool(spec, input10, { cwd: directory ?? process.cwd(), ownDir, signal });
22097
22199
  return { content: JSON.stringify(result), output: result };
22098
22200
  }
22099
22201
  };
22100
22202
  }
22101
22203
  function createV2Setup(specs, deps = {}) {
22102
22204
  return async function setup(ctx) {
22103
- const tools = selectedTools(specs, ctx.options, deps.env).map((spec) => v2Tool(ctx, spec));
22205
+ const tools = selectedTools(specs, ctx.options, deps.env).map((spec) => v2Tool(ctx, spec, deps.ownDir));
22104
22206
  const skills = deps.skillsDir === void 0 ? [] : await loadSkills(deps.skillsDir);
22105
22207
  if (tools.length > 0) {
22106
22208
  await ctx.tool.transform((editor) => {
@@ -22118,10 +22220,11 @@ function createV2Setup(specs, deps = {}) {
22118
22220
  // src/opencode/index.ts
22119
22221
  var PLUGIN_ID = "agent-github";
22120
22222
  var SKILLS_DIR = new URL("../skills/", import.meta.url);
22223
+ var OWN_DIR = packageRoot(import.meta.url);
22121
22224
  var plugin = {
22122
22225
  id: PLUGIN_ID,
22123
- setup: createV2Setup(ALL_TOOLS, { skillsDir: SKILLS_DIR }),
22124
- server: createV1Server(ALL_TOOLS)
22226
+ setup: createV2Setup(ALL_TOOLS, { skillsDir: SKILLS_DIR, ownDir: OWN_DIR }),
22227
+ server: createV1Server(ALL_TOOLS, { ownDir: OWN_DIR })
22125
22228
  };
22126
22229
  var index_default = plugin;
22127
22230
  export {
package/mcp.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
3
3
  "mcpServers": {
4
- "github": {
4
+ "agent-github": {
5
5
  "type": "stdio",
6
6
  "command": "node",
7
7
  "args": ["${PLUGIN_ROOT}/dist/mcp.js"]
package/mcp_config.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "mcpServers": {
3
- "github": {
3
+ "agent-github": {
4
4
  "command": "node",
5
5
  "args": ["${PLUGIN_ROOT}/dist/mcp.js"]
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craftsamo/agent-github",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "GitHub tools for coding agents: issues, project boards, pull requests, repositories, releases and Actions.",
5
5
  "author": "craftsamo",
6
6
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  "skills",
27
27
  "plugin.json",
28
28
  "mcp.json",
29
+ ".mcp.json",
29
30
  ".claude-plugin",
30
31
  ".agents/plugins",
31
32
  ".antigravity-plugin",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "agent-github",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "GitHub tools for coding agents: issues, project boards, pull requests, repositories, releases and Actions.",
6
6
  "author": {
7
7
  "name": "craftsamo",
package/plugin.yaml CHANGED
@@ -1,5 +1,5 @@
1
1
  name: agent-github
2
- version: 0.1.0
2
+ version: 0.2.0
3
3
  description: "GitHub tools for coding agents: issues, project boards, pull requests, repositories, releases and Actions."
4
4
  manifest_version: 1
5
5
  requires_hermes: ">=0.22"
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import uuid
6
7
  from collections.abc import Callable
7
8
  from typing import Any
8
9
 
@@ -13,25 +14,27 @@ from .runtime import Runtime, agent_cwd
13
14
  #: How long ``target`` may take, in seconds. Hermes stops a ``pre_tool_call`` hook after 30 s and blocks the call.
14
15
  TARGET_TIMEOUT_SECONDS = 20
15
16
 
16
- #: What an approval rule matches when the repository a call targets cannot be told.
17
+ #: What an approval rule matches when a repository a call writes to cannot be told.
17
18
  ANY_RESOURCE = "*"
18
19
 
19
20
  #: The Hermes hook that can hold a tool call for the person's approval.
20
21
  HOOK_NAME = "pre_tool_call"
21
22
 
22
23
 
23
- def resolve_target(runtime: Runtime, tool_id: str, args: Any) -> str:
24
- """The repository a call acts on, in lowercase, or ``*`` when it cannot be told."""
24
+ def resolve_resources(runtime: Runtime, tool_id: str, args: Any) -> list[str]:
25
+ """The repositories a call writes to, in lowercase, or ``["*"]`` when they cannot be told."""
25
26
  try:
26
27
  result = runtime.run(
27
28
  ["target", tool_id, "--cwd", agent_cwd()],
28
29
  stdin=json.dumps(args if isinstance(args, dict) else {}),
29
30
  timeout=TARGET_TIMEOUT_SECONDS,
30
31
  )
31
- resource = json.loads(result.stdout).get("resource") if result.code == 0 else None
32
- except Exception: # an approval is asked for either way; only its resource is lost
33
- return ANY_RESOURCE
34
- return resource if isinstance(resource, str) and resource else ANY_RESOURCE
32
+ resources = json.loads(result.stdout).get("resources") if result.code == 0 else None
33
+ except Exception: # an approval is asked for either way; only its resources are lost
34
+ return [ANY_RESOURCE]
35
+ if isinstance(resources, list) and resources and all(isinstance(item, str) and item for item in resources):
36
+ return resources
37
+ return [ANY_RESOURCE]
35
38
 
36
39
 
37
40
  def make_hook(runtime: Runtime, tools: list[ToolInfo]) -> Callable[..., dict[str, str] | None]:
@@ -46,13 +49,14 @@ def make_hook(runtime: Runtime, tools: list[ToolInfo]) -> Callable[..., dict[str
46
49
  tool = by_id.get(tool_name)
47
50
  if tool is None or tool.write_class == READ_CLASS:
48
51
  return None
49
- resource = resolve_target(runtime, tool.id, args)
50
- subject = tool.title if resource == ANY_RESOURCE else f"{tool.title} on {resource}"
51
- return {
52
- "action": "approve",
53
- "message": f"{subject} ({tool.id})",
54
- "rule_key": f"agent-github:{tool.id}:{resource}",
55
- }
52
+ resources = resolve_resources(runtime, tool.id, args)
53
+ subject = tool.title if resources == [ANY_RESOURCE] else f"{tool.title} on {', '.join(resources)}"
54
+ rule_key = f"agent-github:{tool.id}:{','.join(resources)}"
55
+ if ANY_RESOURCE in resources:
56
+ # Hermes falls back to the bare tool name for a missing rule_key, which an "always" would turn into a
57
+ # grant for every repository; a key no later call repeats leaves nothing reusable.
58
+ rule_key = f"{rule_key}:once-{uuid.uuid4().hex}"
59
+ return {"action": "approve", "message": f"{subject} ({tool.id})", "rule_key": rule_key}
56
60
 
57
61
  return hook
58
62