hilos-agent 0.11.6 → 0.11.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.11.6",
3
+ "version": "0.11.7",
4
4
  "description": "Run your own coding agent (Claude Code, Codex, Cursor, OpenCode, Hermes, or any command) as a teammate in a hilos room. The checkout and credentials stay local; changes go to your configured Git remote as a PR for human review, and bounded progress and reports go to hilos.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -215,6 +215,7 @@ export async function runAcpSession({
215
215
  onEvent,
216
216
  requestPermission,
217
217
  getPermissionDecision,
218
+ localAllow = null,
218
219
  /** Session to continue (0778); null starts a fresh one. */
219
220
  resumeSessionId = null,
220
221
  mcpServers = [],
@@ -333,6 +334,7 @@ export async function runAcpSession({
333
334
  now,
334
335
  log,
335
336
  label: "acp permission",
337
+ localAllow,
336
338
  });
337
339
  const option = pickAcpPermissionOption(msg.params?.options, reply);
338
340
  if (option && (reply !== "reject" || option.kind?.startsWith("reject"))) {
@@ -228,6 +228,7 @@ export async function startClaudePermissionServer({
228
228
  // file to delete; a second config would be a second lifetime to get wrong.
229
229
  extraServers = null,
230
230
  allowedTools = "",
231
+ localAllow = null,
231
232
  }) {
232
233
  if (typeof requestPermission !== "function" || typeof getPermissionDecision !== "function") {
233
234
  throw new Error("claude permission server requires requestPermission and getPermissionDecision");
@@ -390,6 +391,7 @@ export async function startClaudePermissionServer({
390
391
  pollIntervalMs,
391
392
  log,
392
393
  label: "claude permission",
394
+ localAllow,
393
395
  });
394
396
  decisionReply = resolved.reply;
395
397
  outcome = resolved.outcome;
@@ -186,6 +186,7 @@ export async function runCodexMcpSession({
186
186
  onEvent,
187
187
  requestPermission,
188
188
  getPermissionDecision,
189
+ localAllow = null,
189
190
  beforeSpawn,
190
191
  spawnImpl = spawn,
191
192
  sleep,
@@ -316,6 +317,7 @@ export async function runCodexMcpSession({
316
317
  now,
317
318
  log,
318
319
  label: "codex permission",
320
+ localAllow,
319
321
  });
320
322
  respond(msg.id, { decision: mapCodexDecision(reply, available) });
321
323
  }
package/src/config.mjs CHANGED
@@ -118,6 +118,11 @@ const DEFAULTS = {
118
118
  // ON by default. `childMcp: false` here turns it off; HILOS_CHILD_MCP=off (or
119
119
  // =on) does the same from the environment and wins over the file.
120
120
  childMcp: true,
121
+ // 1256 — edits inside the run's own checkout are allowed on the daemon and
122
+ // never raise a permission card: the pull request is the gate. `true` here
123
+ // (or HILOS_GATE_REPO_EDITS=1) restores a card per file for rooms that
124
+ // want it.
125
+ gateRepoEdits: false,
121
126
  // Replies in an exact thread bound by an opted-in local-session hook resume
122
127
  // that same provider session (0847). The hook install is the consent gate;
123
128
  // this switch lets an operator pause inbound pickup without uninstalling it.
@@ -234,6 +239,12 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
234
239
  : /^(0|off|false|no)$/i.test(process.env.HILOS_CHILD_MCP || "")
235
240
  ? false
236
241
  : undefined,
242
+ gateRepoEdits:
243
+ /^(1|on|true|yes)$/i.test(process.env.HILOS_GATE_REPO_EDITS || "")
244
+ ? true
245
+ : /^(0|off|false|no)$/i.test(process.env.HILOS_GATE_REPO_EDITS || "")
246
+ ? false
247
+ : undefined,
237
248
  };
238
249
  const merged = { ...DEFAULTS, ...file };
239
250
  for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
package/src/handler.mjs CHANGED
@@ -104,6 +104,7 @@ import {
104
104
  startChildHilosMcp,
105
105
  } from "./child-mcp.mjs";
106
106
  import { createUngatedRunNotice } from "./permission-gate.mjs";
107
+ import { repoEditAllowance } from "./permission-gate.mjs";
107
108
  import { webMcpAgentPrompt } from "./webmcp-bridge.mjs";
108
109
  import {
109
110
  conflictCommitTitle,
@@ -361,8 +362,14 @@ function openCodePermissionCallbacks({
361
362
  // decides, instead of the gate discovering the decision on its next 1s poll.
362
363
  // 0 (older servers) keeps the plain immediate read.
363
364
  decisionWaitMs = 0,
365
+ // 1256 — the checkout this run edits; an edit under it is allowed on the
366
+ // daemon without a card (the pull request is the gate). Empty means "no
367
+ // local allowance": every ask still reaches the room.
368
+ repoRoot = "",
369
+ gateRepoEdits = false,
364
370
  }) {
365
371
  return {
372
+ localAllow: (request) => repoEditAllowance(request, { repoRoot, gateRepoEdits }),
366
373
  requestPermission: async (request) => {
367
374
  const detail =
368
375
  request.metadata && typeof request.metadata === "object"
@@ -2938,6 +2945,8 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2938
2945
  threadRoot,
2939
2946
  decisionWaitMs: caps.decisionWaitMs,
2940
2947
  provider: vendor,
2948
+ repoRoot: folderPath,
2949
+ gateRepoEdits: cfg.gateRepoEdits === true,
2941
2950
  }),
2942
2951
  });
2943
2952
  } else if (gateClaudePermissions) {
@@ -2959,6 +2968,8 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2959
2968
  threadRoot,
2960
2969
  decisionWaitMs: caps.decisionWaitMs,
2961
2970
  provider: vendor,
2971
+ repoRoot: folderPath,
2972
+ gateRepoEdits: cfg.gateRepoEdits === true,
2962
2973
  }),
2963
2974
  });
2964
2975
  } else {
@@ -4598,6 +4609,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {}, iter
4598
4609
  decisionWaitMs: caps.decisionWaitMs,
4599
4610
  runId,
4600
4611
  provider: vendor,
4612
+ repoRoot: repoPath,
4613
+ gateRepoEdits: cfg.gateRepoEdits === true,
4601
4614
  }),
4602
4615
  });
4603
4616
  } else if (gateClaudePermissions) {
@@ -4624,6 +4637,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {}, iter
4624
4637
  decisionWaitMs: caps.decisionWaitMs,
4625
4638
  runId,
4626
4639
  provider: vendor,
4640
+ repoRoot: repoPath,
4641
+ gateRepoEdits: cfg.gateRepoEdits === true,
4627
4642
  }),
4628
4643
  });
4629
4644
  } else {
@@ -13,6 +13,7 @@
13
13
  // the same block, the same timeout" a mechanical fact instead of three
14
14
  // hand-copied implementations that can drift apart.
15
15
 
16
+ import path from "node:path";
16
17
  import { mapOpenCodePermissionDecision } from "./opencode-permissions.mjs";
17
18
 
18
19
  export const DEFAULT_PERMISSION_TIMEOUT_MS = 30 * 60_000;
@@ -136,6 +137,59 @@ function withDeadline(promise, { signal, deadlineAt, now, label }) {
136
137
  });
137
138
  }
138
139
 
140
+ /**
141
+ * Edits inside the run's own checkout never raise a card (1256).
142
+ *
143
+ * The room asked the agent to change this repository, the daemon commits the
144
+ * result to a branch, and a person decides at the pull request. Asking that
145
+ * same person to approve every file the agent touches on the way is the
146
+ * ten-minute silence seen live on 1198 step 6: a permission card for the very
147
+ * README the ask named, and nobody there to click it. So a file edit whose
148
+ * every path resolves under the repo root (and outside `.git`) is allowed
149
+ * here, on the daemon, before hilos is asked. Everything else keeps the gate:
150
+ * shell commands, network, writes outside the tree, and any request whose
151
+ * paths the daemon cannot see. `gateRepoEdits: true` restores the old asking.
152
+ */
153
+ export const REPO_EDIT_ACTIONS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "edit"]);
154
+
155
+ function requestPaths(request) {
156
+ const out = [];
157
+ const meta = request?.metadata && typeof request.metadata === "object" ? request.metadata : {};
158
+ if (typeof meta.path === "string" && meta.path) out.push(meta.path);
159
+ for (const r of Array.isArray(request?.resources) ? request.resources : []) {
160
+ if (typeof r === "string" && r.startsWith("/")) out.push(r);
161
+ }
162
+ // The same file rides in both `metadata.path` and `resources`; one file is one file.
163
+ return [...new Set(out)];
164
+ }
165
+
166
+ function underRoot(candidate, repoRoot) {
167
+ const resolved = path.resolve(repoRoot, candidate);
168
+ const rel = path.relative(repoRoot, resolved);
169
+ if (!rel || rel === "") return false;
170
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
171
+ if (rel === ".git" || rel.startsWith(".git" + path.sep)) return false;
172
+ return true;
173
+ }
174
+
175
+ export function repoEditAllowance(request, { repoRoot = "", gateRepoEdits = false } = {}) {
176
+ if (gateRepoEdits || !repoRoot || !request) return null;
177
+ if (!REPO_EDIT_ACTIONS.has(String(request.action || ""))) return null;
178
+ const root = path.resolve(repoRoot);
179
+ const meta = request.metadata && typeof request.metadata === "object" ? request.metadata : {};
180
+ const paths = requestPaths(request);
181
+ if (paths.length) {
182
+ if (!paths.every((p) => underRoot(p, root))) return null;
183
+ return { reason: `edit inside the repository (${paths.length === 1 ? path.relative(root, path.resolve(root, paths[0])) : paths.length + " files"})` };
184
+ }
185
+ // Codex patch approvals name no files, only the directory the patch applies
186
+ // in; a patch applied inside the checkout is the same edit.
187
+ if (typeof meta.cwd === "string" && meta.cwd && (path.resolve(meta.cwd) === root || underRoot(meta.cwd, root))) {
188
+ return { reason: "patch applied inside the repository" };
189
+ }
190
+ return null;
191
+ }
192
+
139
193
  /**
140
194
  * Raise one permission card and block until a human decides it.
141
195
  *
@@ -153,8 +207,9 @@ function withDeadline(promise, { signal, deadlineAt, now, label }) {
153
207
  * settlementTimeoutMs?: number,
154
208
  * sleep?: (ms: number) => Promise<void>,
155
209
  * now?: () => number,
156
- * log?: { error?: (message: string) => void },
210
+ * log?: { error?: (message: string) => void, info?: (message: string) => void },
157
211
  * label?: string,
212
+ * localAllow?: ((request: Record<string, unknown>) => { reason?: string } | null) | null,
158
213
  * }} options
159
214
  * @returns {Promise<{ reply: "once"|"always"|"reject", outcome: string, handle: unknown }>}
160
215
  */
@@ -171,10 +226,18 @@ export async function resolveHilosPermissionReply({
171
226
  now = () => Date.now(),
172
227
  log,
173
228
  label = "permission",
229
+ localAllow = null,
174
230
  }) {
175
231
  if (typeof requestPermission !== "function" || typeof getPermissionDecision !== "function") {
176
232
  throw new Error("permission gate requires requestPermission and getPermissionDecision");
177
233
  }
234
+ // 1256 — a daemon-side allowance answers before hilos is asked: no card, no
235
+ // wait, one log line so the run's exhaust still says what happened.
236
+ const local = typeof localAllow === "function" ? localAllow(request) : null;
237
+ if (local && typeof local === "object") {
238
+ log?.info?.(`${label}: allowed on the daemon, ${local.reason || "local rule"}`);
239
+ return { reply: "once", outcome: "local-allow", handle: null, byPolicy: false, policyReason: null, localReason: local.reason || null };
240
+ }
178
241
  const deadline = deadlineAt ?? now() + Math.max(1, timeoutMs || DEFAULT_PERMISSION_TIMEOUT_MS);
179
242
  let handle = null;
180
243
  let reply = "reject";