@staix/agent-hub 0.18.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/CHANGELOG.md +19 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -8
- package/docs/agent-notes/daemon.md +5 -2
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +4 -1
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +9 -3
- package/docs/memory.md +1 -1
- package/docs/operations.md +66 -28
- package/docs/quickstart.md +9 -5
- package/docs/security.md +22 -13
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +16 -5
- package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verified.json +14 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +273 -68
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +5 -3
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -2
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/pi.ts +39 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/main.ts +30 -24
- package/src/cli/output.ts +4 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +14 -2
- package/src/hub/check-runner.ts +148 -18
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +2 -2
- package/src/hub/daemon.ts +197 -35
- package/src/hub/host-executable.ts +20 -1
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +7 -0
- package/src/hub/operational-view.ts +6 -10
- package/src/hub/read-only-shell-proof.ts +7 -6
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +78 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +139 -13
- package/src/local/deny.ts +91 -11
- package/src/local/sandbox.ts +67 -28
- package/src/local/shell-environment.ts +10 -0
- package/src/ui/index.html +1 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
|
|
2
4
|
import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
|
|
3
5
|
import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
|
|
4
6
|
import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
|
|
@@ -16,6 +18,7 @@ import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type P
|
|
|
16
18
|
import { readMergeQueue, type DeliveryTask } from "./sdlc-queue.ts";
|
|
17
19
|
import { guardedMerge, mergeModeFor, mergeView, type MergeView } from "./sdlc-merge.ts";
|
|
18
20
|
import { mirrorVerdict as mirrorToGitHub, type MirrorVerdict } from "./sdlc-mirror.ts";
|
|
21
|
+
import { CODE_SCOPE_LINE, evidenceOnlyNotice, type ReviewScope } from "./review-scope.ts";
|
|
19
22
|
import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
|
|
20
23
|
import { readReleaseReadiness } from "./sdlc-release.ts";
|
|
21
24
|
import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
|
|
@@ -104,6 +107,8 @@ export interface TasksDeps {
|
|
|
104
107
|
privateTurn?: (peer: PeerId) => boolean;
|
|
105
108
|
/** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
|
|
106
109
|
reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
|
|
110
|
+
/** #852: what a reviewer reads. `code` (the fixed sandboxed reviewers by default, or a local `review_scope`) reads the code only; unset or `full` also runs tests and reads CI. */
|
|
111
|
+
reviewScope?: (peer: PeerId) => ReviewScope;
|
|
107
112
|
/** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
|
|
108
113
|
reviewOnly?: (peer: PeerId) => boolean;
|
|
109
114
|
/** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
|
|
@@ -698,6 +703,8 @@ export class Tasks {
|
|
|
698
703
|
...(h.selfReview ? { selfReview: true } : {}),
|
|
699
704
|
...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
|
|
700
705
|
...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
|
|
706
|
+
...(h.reviewScope === "code" ? { reviewScope: "code" as const } : {}),
|
|
707
|
+
...(h.evidenceOnly ? { evidenceOnly: true as const } : {}),
|
|
701
708
|
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
702
709
|
...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
|
|
703
710
|
...(h.reviewBlocked ? { reviewBlocked: true } : {}),
|
|
@@ -775,7 +782,7 @@ export class Tasks {
|
|
|
775
782
|
if (h.owner === undefined || h.owner !== owner) since = i;
|
|
776
783
|
owner = h.owner;
|
|
777
784
|
});
|
|
778
|
-
return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
|
|
785
|
+
return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested" && h.evidenceOnly !== true).map((h) => h.by));
|
|
779
786
|
}
|
|
780
787
|
|
|
781
788
|
/** Workload is board/queue data only: wip() never calls health() or weights(). */
|
|
@@ -872,7 +879,7 @@ export class Tasks {
|
|
|
872
879
|
if (!OWNERSHIP_EVENTS.has(given.event) || given.event === "unassigned" || given.owner !== peer || given.profile !== profile || given.by === peer) return [];
|
|
873
880
|
const next = t.history.findIndex((h, j) => j > i && OWNERSHIP_EVENTS.has(h.event));
|
|
874
881
|
const span = t.history.slice(i + 1, next < 0 ? undefined : next + 1);
|
|
875
|
-
if (span.some((h) => failed.includes(h.event))) return [{ outcome: "failed" }];
|
|
882
|
+
if (span.some((h) => failed.includes(h.event) && h.evidenceOnly !== true)) return [{ outcome: "failed" }];
|
|
876
883
|
if (next >= 0 || t.state !== "approved") return []; // handed on without a failure (a relay), or still open
|
|
877
884
|
const accepted = span.find((h) => h.event === "accepted" && h.by === peer);
|
|
878
885
|
// The work stage ends at its first done call: checks and an integration step are not the work itself.
|
|
@@ -1038,6 +1045,85 @@ export class Tasks {
|
|
|
1038
1045
|
wip(peer: PeerId, except?: number): number {
|
|
1039
1046
|
return this.d.board.plannerWip(peer, except);
|
|
1040
1047
|
}
|
|
1048
|
+
|
|
1049
|
+
/** Host-observed task branch/root binding (#845), never a tool-call branch claim or an execution grant. */
|
|
1050
|
+
bindShellBranch(peer: PeerId, id: number, binding: TaskShellBinding & { worktree: string }, current: () => boolean): boolean {
|
|
1051
|
+
try {
|
|
1052
|
+
if (!Number.isSafeInteger(id) || id < 1 || !taskShellBindingValid(binding) || !this.publicMetadataText(binding.branch)
|
|
1053
|
+
|| !current() || this.d.privateTurn?.(peer)) return false;
|
|
1054
|
+
const task = this.d.board.get(id), root = this.d.executionRoot?.(peer);
|
|
1055
|
+
if (!task || task.owner !== peer || !["in_progress", "changes_requested"].includes(task.state) || this.isPii(task)
|
|
1056
|
+
|| !root || realPath(root) === realPath(this.d.cwd) || realPath(root) !== realPath(binding.worktree)) return false;
|
|
1057
|
+
const worktree = createHash("sha256").update(realPath(root)).digest("hex");
|
|
1058
|
+
const previous = task.history.findLast(entry => entry.by === HUB && entry.event === "shell branch bound");
|
|
1059
|
+
if (previous) {
|
|
1060
|
+
const saved: unknown = JSON.parse(previous.note ?? "");
|
|
1061
|
+
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return false;
|
|
1062
|
+
const record = saved as Record<string, unknown>;
|
|
1063
|
+
if (record.branch !== binding.branch || record.worktree !== worktree) return false;
|
|
1064
|
+
}
|
|
1065
|
+
if (task.refs.branch !== undefined && task.refs.branch !== binding.branch) return false;
|
|
1066
|
+
const scope = this.repositories.scope(task.refs);
|
|
1067
|
+
const others = this.d.board.list().filter(row => row.id !== id && !isClosedTaskState(row.state))
|
|
1068
|
+
.map(row => ({ row, scope: this.repositories.scope(row.refs) }));
|
|
1069
|
+
if (scope.key !== this.repositories.project.key || others.some(other => other.scope.key === scope.key && other.row.refs.branch === binding.branch)) return false;
|
|
1070
|
+
if (!current() || this.d.privateTurn?.(peer) || this.d.board.get(id)?.history.length !== task.history.length) return false;
|
|
1071
|
+
if (previous) return true;
|
|
1072
|
+
let recorded = false;
|
|
1073
|
+
this.releaseNotice(`task #${id}: execution branch binding unavailable`, () => {
|
|
1074
|
+
this.d.board.update(id, HUB, "shell branch bound", { refs: { branch: binding.branch } }, JSON.stringify({ branch: binding.branch, worktree }), {}, task.history.length);
|
|
1075
|
+
recorded = true;
|
|
1076
|
+
});
|
|
1077
|
+
return recorded;
|
|
1078
|
+
} catch { return false; }
|
|
1079
|
+
}
|
|
1080
|
+
private capacityBefore(task: Task): Map<PeerId, number> {
|
|
1081
|
+
return new Map([...new Set([task.owner, task.reserved])].filter((peer): peer is PeerId => !!peer).map(peer => [peer, this.wip(peer)]));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** Write-driven wake-ups (#785): one unsettled ids-only task notice per planner, never a polling pull. */
|
|
1085
|
+
private writePlannerNotices(task: Task, before: ReadonlyMap<PeerId, number>, approved = false): void {
|
|
1086
|
+
this.releaseNotice(`task #${task.id}: could not notify planner about ready work`, () => {
|
|
1087
|
+
const changes = new Map<PeerId, { capacity: Set<PeerId>; ready: Set<number> }>();
|
|
1088
|
+
const add = (planner: PeerId, peer?: PeerId, id?: number) => {
|
|
1089
|
+
if (!this.d.bus.isRostered(planner)) return;
|
|
1090
|
+
const change = changes.get(planner) ?? { capacity: new Set<PeerId>(), ready: new Set<number>() };
|
|
1091
|
+
if (peer) change.capacity.add(peer);
|
|
1092
|
+
if (id !== undefined) change.ready.add(id);
|
|
1093
|
+
changes.set(planner, change);
|
|
1094
|
+
};
|
|
1095
|
+
for (const [peer, used] of before) if (this.wip(peer) < used) for (const planner of this.taskPlanner(task)) add(planner, peer);
|
|
1096
|
+
if (approved) for (const card of this.d.board.list("backlog")) {
|
|
1097
|
+
if (card.deps?.includes(task.id) && !this.waitsFor(card).length) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
|
|
1098
|
+
}
|
|
1099
|
+
for (const [planner, change] of changes) this.releaseNotice(`could not notify planner ${planner} about ready work`, () => {
|
|
1100
|
+
const pending = this.d.bus.pendingEnvelopes(planner).filter(env => env.from === HUB && env.kind === "task" && env.body.startsWith("Planner work ready:"));
|
|
1101
|
+
// The bus retains these hub-written lines across restart; an in-memory cache would lose unsettled events.
|
|
1102
|
+
for (const env of pending) for (const line of env.body.split("\n")) {
|
|
1103
|
+
const capacity = /^capacity_released: (\S+) WIP \d+; reserved ready (?:#\d+|none)$/.exec(line);
|
|
1104
|
+
if (capacity) change.capacity.add(capacity[1]!);
|
|
1105
|
+
const ready = /^backlog_ready: #(\d+)$/.exec(line);
|
|
1106
|
+
if (ready && Number.isSafeInteger(Number(ready[1]))) change.ready.add(Number(ready[1]));
|
|
1107
|
+
}
|
|
1108
|
+
const lines: string[] = [];
|
|
1109
|
+
for (const peer of change.capacity) {
|
|
1110
|
+
const top = this.d.board.list().filter(card => ["backlog", "proposed"].includes(card.state) && !card.owner && card.reserved === peer
|
|
1111
|
+
&& this.taskPlanner(card).includes(planner) && !card.pinned && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
|
|
1112
|
+
&& (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
|
|
1113
|
+
lines.push(`capacity_released: ${peer} WIP ${this.wip(peer)}; reserved ready ${top ? `#${top.id}` : "none"}`);
|
|
1114
|
+
}
|
|
1115
|
+
for (const id of change.ready) {
|
|
1116
|
+
const card = this.d.board.get(id);
|
|
1117
|
+
if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length) lines.push(`backlog_ready: #${id}`);
|
|
1118
|
+
else change.ready.delete(id);
|
|
1119
|
+
}
|
|
1120
|
+
for (const env of pending) this.d.bus.withdraw(env.id);
|
|
1121
|
+
const env = newEnvelope(HUB, sanitize(`Planner work ready:\n${lines.join("\n")}\nRead the current board before planning or pulling work.`), { to: [planner], kind: "task" });
|
|
1122
|
+
if (!this.d.bus.publish(env).includes(planner)) throw new Error("the notice was not queued");
|
|
1123
|
+
});
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1041
1127
|
/** #784 AC3: the read-only breakdown of `wip` for status, the console and the dashboard: one grouped query for every peer. Admission still reads `wip()`. */
|
|
1042
1128
|
wipBreakdown(peers: readonly PeerId[]): Record<string, { wip: number; active: number; reviewWait: number; reserved: number; wipLimit: number }> {
|
|
1043
1129
|
const grouped = this.d.board.plannerWipBreakdown();
|
|
@@ -1380,7 +1466,9 @@ export class Tasks {
|
|
|
1380
1466
|
if (entry.planning.pull) {
|
|
1381
1467
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
1382
1468
|
if (task.state !== "proposed" || task.owner || taskHasStarted(task) || by !== USER && (task.pinned || release && ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull undo is no longer eligible");
|
|
1469
|
+
const capacity = this.capacityBefore(task);
|
|
1383
1470
|
const returned = this.d.board.undoPlannedPull(id, by, revision);
|
|
1471
|
+
this.writePlannerNotices(returned, capacity);
|
|
1384
1472
|
this.offered.delete(id); this.incomingBacklog.add(id);
|
|
1385
1473
|
this.planningRecorded({ by, task: id, fields: ["pull", "reserved"], revision: returned.planningRevision! });
|
|
1386
1474
|
return returned;
|
|
@@ -1760,7 +1848,9 @@ export class Tasks {
|
|
|
1760
1848
|
this.plannerWriteAuthority(by);
|
|
1761
1849
|
const confirmed = validate();
|
|
1762
1850
|
if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
|
|
1851
|
+
const capacity = this.capacityBefore(confirmed);
|
|
1763
1852
|
const returned = this.d.board.update(confirmed.id, by, "backlog returned untouched", { state: "backlog", owner: null, reviewer: null }, input.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
|
|
1853
|
+
this.writePlannerNotices(returned, capacity);
|
|
1764
1854
|
this.offered.delete(confirmed.id);
|
|
1765
1855
|
this.cohorts.leave(confirmed.id);
|
|
1766
1856
|
this.withdrawRoleRequest(confirmed, returned, "owner");
|
|
@@ -1783,7 +1873,9 @@ export class Tasks {
|
|
|
1783
1873
|
if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
|
|
1784
1874
|
const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
|
|
1785
1875
|
const restoreReservation = !pull && pulled?.planning?.pull && !task.history.slice(task.history.lastIndexOf(pulled) + 1).some(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved"));
|
|
1876
|
+
const capacity = this.capacityBefore(task);
|
|
1786
1877
|
const next = this.d.board.update(n, by, pull ? "backlog pulled" : "backlog returned", { state: pull ? "proposed" : "backlog", owner: null, reviewer: null, ...(restoreReservation ? { reserved: pulled.planning!.previous.reserved ?? null } : {}) });
|
|
1878
|
+
this.writePlannerNotices(next, capacity);
|
|
1787
1879
|
this.offered.delete(n);
|
|
1788
1880
|
if (!pull) { this.incomingBacklog.add(n); return next; }
|
|
1789
1881
|
const waits = this.waitsFor(next);
|
|
@@ -2612,7 +2704,9 @@ export class Tasks {
|
|
|
2612
2704
|
const task = validate();
|
|
2613
2705
|
if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
|
|
2614
2706
|
const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
|
|
2707
|
+
const capacity = this.capacityBefore(task);
|
|
2615
2708
|
const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
|
|
2709
|
+
this.writePlannerNotices(next, capacity);
|
|
2616
2710
|
this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
|
|
2617
2711
|
this.offered.delete(task.id);
|
|
2618
2712
|
this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
|
|
@@ -2863,7 +2957,7 @@ export class Tasks {
|
|
|
2863
2957
|
}
|
|
2864
2958
|
const rows = this.d.board.list();
|
|
2865
2959
|
const tasks: MetricTask[] = rows.map(task => ({ id: task.id, private: this.isPii(task), history: task.history.map(entry => ({ at: entry.at, event: entry.event,
|
|
2866
|
-
...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}) })) }));
|
|
2960
|
+
...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}), ...(entry.evidenceOnly ? { evidenceOnly: true as const } : {}) })) }));
|
|
2867
2961
|
const visibleIds = new Set(rows.filter(task => !this.isPii(task)).map(task => task.id));
|
|
2868
2962
|
const byKind: Record<string, number> = {};
|
|
2869
2963
|
for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
|
|
@@ -3688,12 +3782,17 @@ export class Tasks {
|
|
|
3688
3782
|
const task = this.need(id, true, true);
|
|
3689
3783
|
this.mine(task, by, "owner", true);
|
|
3690
3784
|
// The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
|
|
3785
|
+
const capacityBefore = this.capacityBefore(task);
|
|
3691
3786
|
const planned = this.plannerPullRecord(task)?.pull === true;
|
|
3692
3787
|
const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner, ...(capacity === true ? { capacity: true as const } : {}) } : {});
|
|
3693
3788
|
if (planned) this.offered.delete(task.id);
|
|
3694
3789
|
this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
3695
3790
|
// A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
|
|
3696
|
-
|
|
3791
|
+
try {
|
|
3792
|
+
return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
|
|
3793
|
+
} finally {
|
|
3794
|
+
this.writePlannerNotices(this.d.board.get(task.id) ?? back, capacityBefore);
|
|
3795
|
+
}
|
|
3697
3796
|
}
|
|
3698
3797
|
|
|
3699
3798
|
/**
|
|
@@ -4037,7 +4136,9 @@ export class Tasks {
|
|
|
4037
4136
|
const completedRefs = cleanRefs(refs);
|
|
4038
4137
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
4039
4138
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
4139
|
+
const capacity = this.capacityBefore(task);
|
|
4040
4140
|
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
4141
|
+
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
4041
4142
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
4042
4143
|
const memorySymbols = this.completionMemorySymbols(next);
|
|
4043
4144
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld, memorySymbols);
|
|
@@ -4235,9 +4336,12 @@ export class Tasks {
|
|
|
4235
4336
|
? "No check ran for this class: verify the outcome yourself."
|
|
4236
4337
|
: "No check ran for this class: run the affected tests yourself.";
|
|
4237
4338
|
// #824: the hub's own exact-head CI evidence, from the admission this request already carries (no new gh call). Nothing here comes from the builder's summary or a recorded claim.
|
|
4238
|
-
const
|
|
4339
|
+
const codeScoped = this.d.reviewScope?.(reviewer) === "code";
|
|
4340
|
+
const verified = codeScoped ? "" : this.hubVerifiedBlock(task, admission);
|
|
4239
4341
|
const ciPassed = admission?.ready === true && !this.isPii(task);
|
|
4240
|
-
|
|
4342
|
+
// #852 AC2: a code-scope reviewer's sandbox cannot run tests or read CI, so its Hub-verified and check lines are replaced by one fixed line.
|
|
4343
|
+
const checkResult = codeScoped ? CODE_SCOPE_LINE
|
|
4344
|
+
: check ? `Check result: ${(check.note ?? "").split("\n")[0]}`
|
|
4241
4345
|
: ciPassed ? "CI passed at this exact head (see the Hub-verified line above); do not list test execution or CI evidence as unmet: name the specific missing test or behaviour instead."
|
|
4242
4346
|
: noCheckWording;
|
|
4243
4347
|
const checklistTemplates = profile.review_checklist ?? [
|
|
@@ -4431,6 +4535,11 @@ export class Tasks {
|
|
|
4431
4535
|
let parsed: ReviewFinding[];
|
|
4432
4536
|
try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
|
|
4433
4537
|
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
|
|
4538
|
+
// #852 AC3 (amended): a code-scope reviewer's changes_requested is always recorded, never refused. Without an open structured finding it is
|
|
4539
|
+
// evidence-only: it does not count toward rejections or escalation, the task returns to its owner as usual, and the conductor gets one ids-only
|
|
4540
|
+
// notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
|
|
4541
|
+
const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
|
|
4542
|
+
const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
|
|
4434
4543
|
const resolvedHead = this.findingHead(initial);
|
|
4435
4544
|
let findingHead = resolvedHead;
|
|
4436
4545
|
if (head !== undefined) {
|
|
@@ -4512,7 +4621,7 @@ export class Tasks {
|
|
|
4512
4621
|
}
|
|
4513
4622
|
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
|
|
4514
4623
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
4515
|
-
if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
4624
|
+
if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
4516
4625
|
const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
|
|
4517
4626
|
cause: "unknown: review unmet items do not establish a cause", retryWhen: "unknown: a person must review the unmet items before accepting",
|
|
4518
4627
|
evidenceRefs: [`task:${task.id}`, `reviewed-head:${reviewedHead ?? task.refs.commit ?? "unknown"}`] };
|
|
@@ -4531,9 +4640,11 @@ export class Tasks {
|
|
|
4531
4640
|
// #507b: unmet items that start with a recorded criterion id name that criterion. The ids come from the checklist on the board as it is at this
|
|
4532
4641
|
// write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
|
|
4533
4642
|
const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
|
|
4534
|
-
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
4643
|
+
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(codeScope ? { reviewScope: "code" as const } : {}), ...(evidenceOnly ? { evidenceOnly: true as const } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
4535
4644
|
if (verdict === "approved") {
|
|
4645
|
+
const capacity = this.capacityBefore(task);
|
|
4536
4646
|
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
|
|
4647
|
+
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
4537
4648
|
if (next.owner) {
|
|
4538
4649
|
this.d.board.recordOutcome(next.owner, next.class, true);
|
|
4539
4650
|
this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
|
|
@@ -4552,17 +4663,19 @@ export class Tasks {
|
|
|
4552
4663
|
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
4553
4664
|
return next;
|
|
4554
4665
|
}
|
|
4555
|
-
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
|
|
4666
|
+
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
|
|
4556
4667
|
void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
|
|
4557
|
-
this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
4668
|
+
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
4558
4669
|
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
4559
4670
|
try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
|
|
4560
4671
|
}
|
|
4561
4672
|
this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
|
|
4562
|
-
|
|
4563
|
-
this.
|
|
4673
|
+
// Evidence-only is not a verdict on the owner's work: no failed outcome, no contradiction of an earlier approval, and no escalation count.
|
|
4674
|
+
if (rejected.owner && !evidenceOnly) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
4675
|
+
if (!evidenceOnly) this.contradict(rejected);
|
|
4564
4676
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
|
|
4565
|
-
if (
|
|
4677
|
+
if (evidenceOnly) this.tellConductorEvidenceOnly(rejected, by);
|
|
4678
|
+
if (!evidenceOnly && rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
|
|
4566
4679
|
let moved: Task;
|
|
4567
4680
|
try {
|
|
4568
4681
|
moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
@@ -4587,6 +4700,19 @@ export class Tasks {
|
|
|
4587
4700
|
return reopened;
|
|
4588
4701
|
}
|
|
4589
4702
|
|
|
4703
|
+
/**
|
|
4704
|
+
* #852 AC3: one task-weight notice to the configured conductor for a verdict recorded as evidence-only. Ids only (task and reviewer), so nothing of
|
|
4705
|
+
* the review or the task reaches it; a PII task's notice is private. With no conductor the person sees one console notice instead.
|
|
4706
|
+
*/
|
|
4707
|
+
private tellConductorEvidenceOnly(task: Task, reviewer: PeerId): void {
|
|
4708
|
+
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
|
|
4709
|
+
const text = evidenceOnlyNotice(task.id, reviewer);
|
|
4710
|
+
if (!conductors.length) { this.releaseNotice(text); return; }
|
|
4711
|
+
this.releaseNotice(`task #${task.id}: could not tell the conductor a code-scope review was evidence-only`, () => {
|
|
4712
|
+
this.d.bus.publish(newEnvelope(HUB, sanitize(text), { to: conductors, kind: "task", refs: { task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
4713
|
+
});
|
|
4714
|
+
}
|
|
4715
|
+
|
|
4590
4716
|
/** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
|
|
4591
4717
|
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
|
|
4592
4718
|
let task = this.need(id, true, preEffect);
|
package/src/local/deny.ts
CHANGED
|
@@ -31,6 +31,17 @@ const HFS_IGNORABLE = `(${HFS_IGNORABLE_POINTS.join("|")})`;
|
|
|
31
31
|
/** A regex source matching `name` with any number of HFS+-ignorable code points interleaved, at the ends included. */
|
|
32
32
|
const hfsFoldedName = (name: string) => `${HFS_IGNORABLE}*${[...name].map((c) => sbplEscape(c)).join(`${HFS_IGNORABLE}*`)}${HFS_IGNORABLE}*`;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* `config.worktree`, the per-worktree config git reads beside `config` (#518), folded in BOTH halves as two NAME-only regex sources that a rule must both match. One SBPL regex
|
|
36
|
+
* literal dies at 1024 bytes in UTF-8 and every folded character costs about 65 bytes, so the whole name (1071) cannot be one literal, and a byte class cannot hold the
|
|
37
|
+
* multi-byte code points (the engine is byte-oriented, verified), so the fold stays an alternation. The first source pins the front (a folded `config`, a dot, then the rest of
|
|
38
|
+
* the name), the second the end (a folded `worktree`). Together they match every spelling of `config.worktree` with ignorable code points anywhere in it, and also
|
|
39
|
+
* `config.<anything>worktree` in the same directory, which is over-refused on purpose and names no file git uses.
|
|
40
|
+
* The root a rule is scoped to is NEVER part of these literals: it goes in its own `require-all` filter, so a long project or git path cannot push a folded name over the limit
|
|
41
|
+
* (#518 review: a 450-byte path made a 1083-byte literal when the root and the name shared one).
|
|
42
|
+
*/
|
|
43
|
+
const CONFIG_WORKTREE_NAME: readonly [string, string] = [`^.*/${hfsFoldedName("config")}\\.[^/]*$`, `^.*/[^/]*${hfsFoldedName("worktree")}$`];
|
|
44
|
+
|
|
34
45
|
/**
|
|
35
46
|
* Seatbelt rules for the hub's and git's own names (#270). `.agenthub` is refused anywhere under the project root.
|
|
36
47
|
* `.git` is refused at any depth, the name itself included, so a planted repository cannot arrive by creation,
|
|
@@ -53,32 +64,101 @@ const hfsFoldedName = (name: string) => `${HFS_IGNORABLE}*${[...name].map((c) =>
|
|
|
53
64
|
* long project path would trip. ponytail: the modules rules do contain the (plain) modules root; a project path
|
|
54
65
|
* over roughly 600 bytes with submodules would exceed the limit — fold the root's segments if one ever shows up.
|
|
55
66
|
*/
|
|
56
|
-
|
|
67
|
+
/**
|
|
68
|
+
* `hooks` as the entry directly under `parent`, and everything below it, anchored by DEPTH instead of by the unanchored any-prefix name: that one also matches an ancestor of the
|
|
69
|
+
* project that happens to be named `hooks`, and paired with a plain root prefix it refused every write below the root (#520). Seatbelt ignores a `{n}` repeat, so the depth is
|
|
70
|
+
* written out as that many `/[^/]+` terms (verified with the real sandbox-exec), which keeps the literal independent of the path's characters and costs 6 bytes a component.
|
|
71
|
+
* A path with more than MAX_ANCHOR_DEPTH components would push the folded name past the 1024-byte limit, which stops every sandboxed command, so it falls back to the
|
|
72
|
+
* unanchored name: that over-refuses only when an ancestor is named `hooks`, never under-protects. Pair it with a plain `^<parent>/` filter.
|
|
73
|
+
*/
|
|
74
|
+
const MAX_ANCHOR_DEPTH = 80;
|
|
75
|
+
const hooksUnder = (parent: string): string => {
|
|
76
|
+
const depth = parent.split("/").filter(Boolean).length;
|
|
77
|
+
return depth > MAX_ANCHOR_DEPTH ? `^.*/${hfsFoldedName("hooks")}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName("hooks")}(/|$)`;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit: readonly { root: string; entries: string[]; dirs?: string[] }[] = [], worktreesRoot?: string): string[] {
|
|
57
81
|
const git = hfsFoldedName(".git");
|
|
58
82
|
const re = (s: string) => `(regex ${sbplString(s)})`;
|
|
59
83
|
const external = gitDirs.flatMap((d) => [
|
|
60
84
|
`(require-all ${re(`^${sbplEscape(d)}/[^/]+$`)} ${re(`^.*/(${hfsFoldedName("config")}|${hfsFoldedName("hooks")})$`)})`,
|
|
85
|
+
`(require-all ${re(`^${sbplEscape(d)}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
|
|
61
86
|
`(subpath ${sbplString(`${d}/hooks`)})`,
|
|
62
87
|
`(require-all (subpath ${sbplString(d)}) ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
|
|
63
88
|
]);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
// Every modules tree (#518, #520): the common dir's, a linked worktree's own, each sibling's, and the `modules/` of every discovered submodule git dir. Nothing is exempt by name:
|
|
90
|
+
// a `refs` or `logs` component does not make a path safe, because a later unsandboxed `git submodule update --init` reuses any git dir found at `<modules>/<name>` and runs its
|
|
91
|
+
// config and hooks. So under a modules root, everything that is not inside a DISCOVERED git dir is refused (that is a plant, a new or a renamed git dir), and inside each discovered
|
|
92
|
+
// git dir `D` the protected files are anchored to `D` itself: `D/config`, `D/config.worktree`, `D/hooks` and what is below it, and `D/worktrees/*/config.worktree`.
|
|
93
|
+
// Ordinary git work writes `D/refs`, `D/logs`, `D/objects`, `D/index`, `D/HEAD`, ... and none of those is refused, so no refs/logs exception is needed.
|
|
94
|
+
// Each literal holds either a path or a folded name, never both (the 1024-byte limit, #518 review); the folded names sit in `^.*/<name>$` filters beside a plain anchor.
|
|
95
|
+
const allDirs = moduleGit.flatMap((tree) => tree.dirs ?? []);
|
|
96
|
+
const modules = moduleGit.flatMap((tree) => {
|
|
97
|
+
const root = sbplEscape(tree.root);
|
|
98
|
+
const found = tree.dirs ?? []; // absent means nothing is exempt: everything under the root is refused, which fails closed
|
|
99
|
+
const inside = found.map((d) => `(require-not ${re(`^${sbplEscape(d)}(/|$)`)})`).join(" ");
|
|
100
|
+
const anchored = found.flatMap((d) => {
|
|
101
|
+
const dir = sbplEscape(d);
|
|
102
|
+
return [
|
|
103
|
+
`(require-all ${re(`^${dir}/[^/]+$`)} ${re(`^.*/${hfsFoldedName("config")}$`)})`,
|
|
104
|
+
`(require-all ${re(`^${dir}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
|
|
105
|
+
`(require-all ${re(`^${dir}/`)} ${re(hooksUnder(d))})`,
|
|
106
|
+
// A linked worktree of a submodule keeps its own config.worktree one level down.
|
|
107
|
+
`(require-all ${re(`^${dir}/worktrees/[^/]+/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
|
|
108
|
+
// The directories on the way to those files are directory-entry targets, so none can be moved out of the protected path, edited and put back (#520 review): `worktrees`
|
|
109
|
+
// and each `worktrees/<name>` (no rename, replace, symlink or removal), and the `hooks` directory itself. The root and the folded name are separate filters.
|
|
110
|
+
`(require-all ${re(`^${dir}/[^/]+$`)} ${re(`^.*/${hfsFoldedName("worktrees")}$`)})`,
|
|
111
|
+
re(`^${dir}/worktrees/[^/]+$`),
|
|
112
|
+
`(require-all ${re(`^${dir}/[^/]+$`)} ${re(`^.*/${hfsFoldedName("hooks")}$`)})`,
|
|
113
|
+
// A submodule's own linked worktrees keep their `modules/` at `D/worktrees/<wt>/modules` (#520 review), inside the exemption above. Everything under one that is not inside a
|
|
114
|
+
// DISCOVERED git dir is refused, whether the worktree exists yet or not (the pattern needs no discovery), and the `modules` entry itself cannot be renamed or replaced.
|
|
115
|
+
`(require-all ${re(`^${dir}/worktrees/[^/]+/modules/.`)} ${allDirs.filter((f) => f.startsWith(`${d}/worktrees/`)).map((f) => `(require-not ${re(`^${sbplEscape(f)}(/|$)`)})`).join(" ")})`,
|
|
116
|
+
`(require-all ${re(`^${dir}/worktrees/[^/]+/[^/]+$`)} ${re(`^.*/${hfsFoldedName("modules")}$`)})`,
|
|
117
|
+
];
|
|
118
|
+
});
|
|
119
|
+
return [
|
|
120
|
+
// Everything under the root that is not inside a discovered git dir. The root's own entry is a directory entry rule below, so this is the contents.
|
|
121
|
+
`(require-all ${re(`^${root}/.`)} ${inside})`,
|
|
122
|
+
...anchored,
|
|
123
|
+
// A submodule git dir as a directory entry: no rename, replace, symlink or recreate. The basename is folded (HFS+ opens the folded spelling as the real entry);
|
|
124
|
+
// the directory and the folded name are separate filters (a ~520-byte git path went over the limit when they shared one, #518 review).
|
|
125
|
+
...tree.entries.map((d) => `(require-all ${re(`^${sbplEscape(dirname(d))}/[^/]+$`)} ${re(`^.*/${hfsFoldedName(basename(d))}$`)})`),
|
|
126
|
+
];
|
|
127
|
+
});
|
|
128
|
+
// Every linked worktree's git dir under `<common>/worktrees/` by pattern, not by discovery (#518): a sibling's `modules/` tree that does not exist yet, or a sibling created
|
|
129
|
+
// after this profile was built, is as protected as one that does. The sandbox can write the common dir, so it could otherwise pre-create them. The worktree name is one
|
|
130
|
+
// plain component (a folded spelling of `worktrees` or of a sibling's name on HFS+ is the same residual as the folded `modules` component above).
|
|
131
|
+
const siblings = worktreesRoot
|
|
132
|
+
? (() => {
|
|
133
|
+
const w = sbplEscape(worktreesRoot);
|
|
134
|
+
return [
|
|
135
|
+
// The ancestors of every protected git dir, as directory entries: no rename, replace, symlink or recreate. Without them the container or a worktree git dir could be moved
|
|
136
|
+
// out of the protected path, its submodule config or hooks edited, and moved back (#518 review). Git itself never renames or removes these during ordinary work.
|
|
137
|
+
// The container's folded name and the directory it sits in are separate filters (one literal holding both grows with the path); a worktree git dir has any name.
|
|
138
|
+
`(require-all ${re(`^${sbplEscape(dirname(worktreesRoot))}/[^/]+$`)} ${re(`^.*/${hfsFoldedName(basename(worktreesRoot))}$`)})`,
|
|
139
|
+
re(`^${w}/[^/]+$`),
|
|
140
|
+
// A worktree git dir's own `config` and `config.worktree`: direct children only.
|
|
141
|
+
`(require-all ${re(`^${w}/[^/]+/[^/]+$`)} ${re(`^.*/${hfsFoldedName("config")}$`)})`,
|
|
142
|
+
`(require-all ${re(`^${w}/[^/]+/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
|
|
143
|
+
// A worktree git dir's own `hooks` directory and everything in it, which git runs for that worktree, anchored by depth so an ancestor named `hooks` is not matched (#520).
|
|
144
|
+
// Its `modules/` is a different component, so this never reaches a submodule's own tree.
|
|
145
|
+
`(require-all ${re(`^${w}/[^/]+/`)} ${re(hooksUnder(`${worktreesRoot}/x`))})`,
|
|
146
|
+
// Every write under any worktree's `modules/` that is not inside a DISCOVERED submodule git dir (#520): a plant, or a tree that does not exist yet. The discovered git dirs
|
|
147
|
+
// (the sandboxed worktree's own, and any sibling's that exists) are exempt here and get their anchored rules from their own tree above. No name is exempt.
|
|
148
|
+
`(require-all ${re(`^${w}/[^/]+/modules/.`)} ${moduleGit.flatMap((tree) => tree.dirs ?? []).filter((d) => d.startsWith(`${worktreesRoot}/`)).map((d) => `(require-not ${re(`^${sbplEscape(d)}(/|$)`)})`).join(" ")})`,
|
|
149
|
+
`(require-all ${re(`^${w}/[^/]+/[^/]+$`)} ${re(`^.*/${hfsFoldedName("modules")}$`)})`,
|
|
150
|
+
];
|
|
151
|
+
})()
|
|
74
152
|
: [];
|
|
75
153
|
return [
|
|
76
154
|
`(require-all (subpath ${sbplString(root)}) ${re(`^.*/${hfsFoldedName(".agenthub")}(/|$)`)})`,
|
|
77
155
|
re(`^.*/${git}$`),
|
|
78
156
|
re(`^.*/${git}/${hfsFoldedName("config")}$`),
|
|
157
|
+
`(require-all ${re(`^.*/${git}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
|
|
79
158
|
re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
|
|
80
159
|
`(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
|
|
81
160
|
...modules,
|
|
161
|
+
...siblings,
|
|
82
162
|
...external,
|
|
83
163
|
];
|
|
84
164
|
}
|
package/src/local/sandbox.ts
CHANGED
|
@@ -48,38 +48,76 @@ function externalGitDirs(root: string): string[] {
|
|
|
48
48
|
return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** `<common>/worktrees`: where every linked worktree keeps its git dir. The rules for it are patterns, so they need no worktree to exist (#518). */
|
|
52
|
+
function worktreesRoot(root: string): string | undefined {
|
|
53
|
+
const out = hubGitSync(["-C", root, "rev-parse", "--git-common-dir"], { encoding: "utf8" });
|
|
54
|
+
return out.status === 0 && out.stdout.trim() ? join(resolve(root, out.stdout.trim()), "worktrees") : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
51
57
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* every component between it and the modules root (#281). Discovered in the hub process, outside the sandbox.
|
|
58
|
+
* One modules tree: its root, the submodule git dirs found directly under it (`dirs`, each a directory holding HEAD and config, however it is named) and every directory entry
|
|
59
|
+
* that must not be renamed, replaced, symlinked or recreated (the root, each git dir and every component between them, #281). Everything else under the root is refused to
|
|
60
|
+
* write (#520): a git dir that is not discovered here is a plant, whatever its name.
|
|
56
61
|
*/
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
export interface ModuleGit { root: string; entries: string[]; dirs?: string[] }
|
|
63
|
+
|
|
64
|
+
/** The deepest submodule nesting the profile follows: a git dir below it is not exempt, so a write there fails closed instead of going unprotected. */
|
|
65
|
+
const MODULE_DEPTH = 8;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The submodule git directories the project can reach, as modules trees. A plain checkout keeps them under the common dir's `modules/`; a LINKED worktree keeps its own under that
|
|
69
|
+
* worktree's git dir, `<common>/worktrees/<wt>/modules/`, so the trees are the common dir's, this worktree's own git dir's (whether or not it exists yet, so it cannot be pre-created)
|
|
70
|
+
* and every sibling worktree's that already has one: the common dir is writable from here (#518). Every discovered git dir also gets a tree of its own for its `modules/`, existing
|
|
71
|
+
* or not, so a submodule of a submodule is covered the same way (#520). Discovered in the hub process, outside the sandbox.
|
|
72
|
+
*/
|
|
73
|
+
function moduleGitDirs(root: string): ModuleGit[] {
|
|
74
|
+
const out = hubGitSync(["-C", root, "rev-parse", "--git-common-dir", "--absolute-git-dir"], { encoding: "utf8" });
|
|
75
|
+
if (out.status !== 0) return [];
|
|
76
|
+
const [commonRaw, ownRaw] = out.stdout.trim().split("\n");
|
|
77
|
+
if (!commonRaw || !ownRaw) return [];
|
|
78
|
+
const common = resolve(root, commonRaw), own = resolve(root, ownRaw);
|
|
79
|
+
const roots = new Set<string>([join(common, "modules"), join(own, "modules")]);
|
|
80
|
+
try {
|
|
81
|
+
for (const e of readdirSync(join(common, "worktrees"), { withFileTypes: true })) {
|
|
82
|
+
if (!e.isDirectory()) continue;
|
|
83
|
+
const modules = join(common, "worktrees", e.name, "modules");
|
|
84
|
+
if (existsSync(modules)) roots.add(modules);
|
|
69
85
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return;
|
|
86
|
+
} catch { /* no linked worktrees */ }
|
|
87
|
+
const trees: ModuleGit[] = [];
|
|
88
|
+
const tree = (modulesRoot: string, nesting: number): void => {
|
|
89
|
+
const dirs: string[] = [];
|
|
90
|
+
const walk = (dir: string, depth: number): void => {
|
|
91
|
+
if (depth > 8) return; // a path this deep is not a submodule layout git makes
|
|
92
|
+
let entries: Dirent[];
|
|
93
|
+
try {
|
|
94
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
95
|
+
} catch {
|
|
96
|
+
return; // no modules tree (no submodules): nothing to discover
|
|
97
|
+
}
|
|
98
|
+
const names = new Set(entries.map((e) => e.name));
|
|
99
|
+
if (names.has("HEAD") && names.has("config")) { dirs.push(dir); return; }
|
|
100
|
+
for (const e of entries) if (e.isDirectory()) walk(join(dir, e.name), depth + 1);
|
|
101
|
+
};
|
|
102
|
+
walk(modulesRoot, 0);
|
|
103
|
+
// Past the nesting limit nothing is exempt, so a write below it is refused rather than left unprotected.
|
|
104
|
+
const found = nesting >= MODULE_DEPTH ? [] : dirs;
|
|
105
|
+
const entries = new Set<string>([modulesRoot]);
|
|
106
|
+
for (const d of found) for (let p = d; p.length > modulesRoot.length; p = dirname(p)) entries.add(p);
|
|
107
|
+
trees.push({ root: modulesRoot, entries: [...entries], dirs: found });
|
|
108
|
+
for (const d of found) {
|
|
109
|
+
tree(join(d, "modules"), nesting + 1); // a submodule of a submodule, whether or not it exists yet
|
|
110
|
+
// A submodule is a repository: its linked worktrees keep their own modules trees at `<d>/worktrees/<wt>/modules`. Existing ones are discovered here; ones that do not exist yet
|
|
111
|
+
// are covered by pattern in hubWriteRegexes (#520 review).
|
|
112
|
+
try {
|
|
113
|
+
for (const e of readdirSync(join(d, "worktrees"), { withFileTypes: true })) {
|
|
114
|
+
if (e.isDirectory() && existsSync(join(d, "worktrees", e.name, "modules"))) tree(join(d, "worktrees", e.name, "modules"), nesting + 1);
|
|
115
|
+
}
|
|
116
|
+
} catch { /* no linked worktrees */ }
|
|
76
117
|
}
|
|
77
|
-
for (const e of entries) if (e.isDirectory()) walk(join(dir, e.name), depth + 1);
|
|
78
118
|
};
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
for (const d of dirs) for (let p = d; p.length > modulesRoot.length; p = dirname(p)) entries.add(p);
|
|
82
|
-
return { root: modulesRoot, entries: [...entries] };
|
|
119
|
+
for (const modulesRoot of roots) tree(modulesRoot, 0);
|
|
120
|
+
return trees;
|
|
83
121
|
}
|
|
84
122
|
|
|
85
123
|
/** The system directories a deny-default profile lets commands read and run from: dyld, frameworks, toolchains. */
|
|
@@ -111,6 +149,7 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
|
|
|
111
149
|
const root = realPath(cwd);
|
|
112
150
|
const gitDirs = externalGitDirs(root);
|
|
113
151
|
const moduleGit = moduleGitDirs(root);
|
|
152
|
+
const worktrees = worktreesRoot(root);
|
|
114
153
|
const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
|
|
115
154
|
const dev = developerDir();
|
|
116
155
|
const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
|
|
@@ -144,7 +183,7 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
|
|
|
144
183
|
// at any depth (creation, rename, symlink, gitfile), a submodule git dir's config and hooks under
|
|
145
184
|
// .git/modules/ are refused at the depth submodules nest (#281), and the external git dirs get folded
|
|
146
185
|
// hooks/config rules (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
|
|
147
|
-
`(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit).join(" ")})`,
|
|
186
|
+
`(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit, worktrees).join(" ")})`,
|
|
148
187
|
`(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
|
|
149
188
|
`(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
|
|
150
189
|
// Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
|
|
3
|
+
export interface HostShellEnvironment extends Record<string, string> { PATH: string; HOME: string; LANG: string; TERM: string }
|
|
4
|
+
|
|
5
|
+
/** The #816 executor's fixed baseline, shared by the proof and host wrapper; never inherit a native agent's environment.
|
|
6
|
+
* ponytail: Seatbelt's accepted source stays byte-identical and adds TMPDIR separately; a future shared-executor
|
|
7
|
+
* extraction must preserve the runtime environment parity test and explicitly revise that source's byte guard. */
|
|
8
|
+
export function hostShellEnvironment(extra: Readonly<Record<string, string>> = {}): HostShellEnvironment {
|
|
9
|
+
return { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", ...extra };
|
|
10
|
+
}
|
package/src/ui/index.html
CHANGED
|
@@ -1674,6 +1674,7 @@ function render(snapshot) {
|
|
|
1674
1674
|
if (peer.pullOnly && adapterDisplay[id]?.pullOnlyRead) more.append(el('p', 'Last hub_inbox read: ' + (typeof peer.lastInboxAt === 'number' ? Math.max(0, Math.floor((Date.now() - peer.lastInboxAt) / 1000)) + 's ago' : 'never'), 'meta'));
|
|
1675
1675
|
if (peer.paused) more.append(el('p', peer.paused, 'meta'));
|
|
1676
1676
|
if (peer.requestedModel) more.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
|
|
1677
|
+
if (peer.reviewScope === 'code' || peer.reviewScope === 'full') more.append(el('p', 'Review scope: ' + (peer.reviewScope === 'code' ? 'code only (cannot run tests or read CI; the merger gates on exact-head CI)' : 'full'), 'meta'));
|
|
1677
1678
|
if (typeof peer.modelSource === 'string' && peer.modelSource) more.append(el('p', 'Model source: ' + peer.modelSource, 'meta'));
|
|
1678
1679
|
for (const backend of peer.backends || []) {
|
|
1679
1680
|
if (backend.requestedModel) more.append(el('p', backend.kind.toUpperCase() + ': ' + (backend.actualModel || 'model not reported') + ' (' + backend.state + ')', 'meta'));
|