@staix/agent-hub 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/CHANGELOG.md +35 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -6
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +11 -2
- package/docs/agent-notes/tests.md +7 -3
- package/docs/dogfood.md +22 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +162 -35
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +15 -5
- package/docs/sdlc.md +29 -3
- package/docs/security.md +44 -13
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
- package/docs/verification/2026-10-25-0.18.0.md +76 -0
- package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
- package/docs/verification/2026-10-25-remote-docker-transport.md +56 -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/verification/profiles/YYYY-MM-DD-general.md +46 -0
- package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
- package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
- package/docs/verified.json +24 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +364 -98
- 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 +3 -3
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +5 -3
- 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/console-state.ts +9 -2
- package/src/cli/help.ts +29 -28
- package/src/cli/identity.ts +87 -6
- package/src/cli/main.ts +198 -71
- package/src/cli/output.ts +11 -2
- package/src/cli/sdlc-output.ts +242 -0
- package/src/cli/task-output.ts +212 -0
- package/src/cli/upgrade-output.ts +57 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +31 -2
- package/src/hub/check-runner.ts +385 -25
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/conductor.ts +5 -0
- package/src/hub/config-apply.ts +16 -7
- package/src/hub/config-edit-host.ts +1 -1
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +3 -3
- package/src/hub/daemon.ts +530 -87
- package/src/hub/dogfood-profile.ts +248 -0
- package/src/hub/dogfood-read.ts +77 -14
- package/src/hub/dogfood.ts +5 -1
- package/src/hub/events.ts +1 -1
- package/src/hub/host-executable.ts +21 -1
- package/src/hub/hub-tools.ts +2 -2
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +67 -0
- package/src/hub/operational-view.ts +137 -0
- package/src/hub/orphan-process.ts +69 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/read-only-shell-proof.ts +81 -0
- package/src/hub/read-only-shell.ts +162 -0
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/reviewer-pool-config.ts +3 -2
- package/src/hub/reviewer-pool.ts +3 -0
- package/src/hub/routing.ts +23 -6
- package/src/hub/sdlc-merge.ts +299 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/sdlc-mirror.ts +129 -0
- package/src/hub/sdlc.ts +58 -12
- package/src/hub/settings.ts +11 -5
- package/src/hub/snapshots.ts +60 -0
- package/src/hub/task-repository.ts +31 -5
- 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 +378 -27
- package/src/local/deny.ts +91 -11
- package/src/local/sandbox.ts +67 -28
- package/src/local/shell-environment.ts +10 -0
- package/src/local/tools.ts +17 -4
- package/src/ui/index.html +3 -1
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/local/tools.ts
CHANGED
|
@@ -17,6 +17,8 @@ const approvalError = (picked: ToolApproval, denied: string): string | undefined
|
|
|
17
17
|
: picked === "expired" ? APPROVAL_EXPIRED_TEXT
|
|
18
18
|
: picked === "aborted" ? "error: approval withdrawn because the turn ended; no operation was executed" : denied;
|
|
19
19
|
|
|
20
|
+
export type ShellPermitPolicy = { forceAsk?: true };
|
|
21
|
+
export type ShellExecutionCheck = "valid" | "ask" | "aborted";
|
|
20
22
|
export interface ToolContext {
|
|
21
23
|
readOnly?: string[];
|
|
22
24
|
cwd: string;
|
|
@@ -29,7 +31,9 @@ export interface ToolContext {
|
|
|
29
31
|
/** Turn cancellation signal, including the execution-budget wall cap. */
|
|
30
32
|
signal?: AbortSignal;
|
|
31
33
|
/** Only true permits an effect; expiry and abort remain distinct from a person's denial. */
|
|
32
|
-
permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown) => Promise<ToolApproval>;
|
|
34
|
+
permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown, policy?: ShellPermitPolicy) => Promise<ToolApproval>;
|
|
35
|
+
/** Host-only synchronous recheck, immediately before a shell process can be spawned. */
|
|
36
|
+
shellExecutionCheck?: (input: unknown) => ShellExecutionCheck;
|
|
33
37
|
/** Trusted process status, independent of what its stdout says. */
|
|
34
38
|
onProcessExit?: (code: number | null) => void;
|
|
35
39
|
/** Publish a message to other peers mid-turn. Returns a one-line receipt. */
|
|
@@ -102,7 +106,7 @@ export const TOOL_SCHEMAS = [
|
|
|
102
106
|
fn("read", "Read a text file inside the project. Returns numbered lines.", { path: str, offset: { type: "integer", minimum: 1 }, limit: { type: "integer", minimum: 1 } }, ["path"]),
|
|
103
107
|
fn("write", "Create or overwrite a file inside the project. Needs the user's approval.", { path: str, content: str }, ["path", "content"]),
|
|
104
108
|
fn("edit", "Replace one exact, unique occurrence of `old` with `new` in a file. Needs the user's approval.", { path: str, old: str, new: str }, ["path", "old", "new"]),
|
|
105
|
-
fn("bash", "Run a shell command in the project directory, sandboxed: no writes outside the project, no credential reads, no network unless the hub enables it.
|
|
109
|
+
fn("bash", "Run a shell command in the project directory, sandboxed: no writes outside the project, no credential reads, no network unless the hub enables it. Follows hub permission policy: proven bounded read-only commands may run automatically in ask-when-needed; other commands ask.", { command: str, timeout_s: { type: "integer", minimum: 1, maximum: 600 } }, ["command"]),
|
|
106
110
|
fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
|
|
107
111
|
fn("hub_send", "Send a message to the other agents now, before your turn ends. Conclusions only. Your final answer is shared anyway; use this for something that cannot wait.", { text: str, to: { type: "array", items: str } }, ["text"]),
|
|
108
112
|
];
|
|
@@ -179,8 +183,17 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
179
183
|
if (!command.trim()) return "error: empty command";
|
|
180
184
|
// The approver sees the whole command, not a prefix: what is hidden cannot be approved.
|
|
181
185
|
if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
|
|
182
|
-
const
|
|
183
|
-
|
|
186
|
+
const input=Object.freeze({...a,command});
|
|
187
|
+
for(let attempt=0;;attempt++) {
|
|
188
|
+
const refused = approvalError(await ctx.permit(`bash: ${command}`, "bash", ctx.signal, undefined, undefined, undefined, input, attempt?{forceAsk:true}:undefined), "error: the user did not approve this command");
|
|
189
|
+
if (refused) return refused;
|
|
190
|
+
if(ctx.signal?.aborted)return "error: turn cancelled before shell execution";
|
|
191
|
+
let check:ShellExecutionCheck="valid";
|
|
192
|
+
try {check=ctx.shellExecutionCheck?.(input)??"valid";}catch{check="ask";}
|
|
193
|
+
if(check==="aborted" || ctx.signal?.aborted)return "error: turn cancelled before shell execution";
|
|
194
|
+
if(check==="valid")break;
|
|
195
|
+
if(attempt)return "error: read-only shell proof remained unstable; no command was executed";
|
|
196
|
+
}
|
|
184
197
|
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
185
198
|
try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
|
|
186
199
|
return `${res.output}\n(exit ${res.code})`;
|
package/src/ui/index.html
CHANGED
|
@@ -1399,7 +1399,8 @@ function renderBoard(snapshot, peers, ids) {
|
|
|
1399
1399
|
const data=(snapshot.boardLegend || []).find(item=>item.peer===id), row=el('div',undefined,'agent-slot');
|
|
1400
1400
|
row.append(peerMark(id),el('p',String(peers[id].state || 'unknown'),'meta'));
|
|
1401
1401
|
const wip=data && Number.isSafeInteger(data.wip) && data.wip>=0 ? data.wip : 'unknown';
|
|
1402
|
-
|
|
1402
|
+
const split=data?.wipBreakdown,splitText=split && [split.active,split.reviewWait,split.reserved].every(Number.isSafeInteger) ? ' (active '+split.active+', review-wait '+split.reviewWait+', reserved '+split.reserved+')' : '';
|
|
1403
|
+
row.append(el('p','WIP '+wip+(Number.isSafeInteger(data?.wipLimit) ? '/'+data.wipLimit : '')+splitText+' / permission '+(data?.permissionMode || 'unknown'),'meta'));
|
|
1403
1404
|
if (Array.isArray(data?.activeTasks) && data.activeTasks.length) row.append(el('p','Active tasks: '+data.activeTasks.map(id=>'#'+id).join(', '),'meta'));
|
|
1404
1405
|
const filter=el('button','View '+id);filter.type='button';filter.addEventListener('click',()=>{if(!boardViewState)return;peerFilter.value=id;renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});row.append(filter);
|
|
1405
1406
|
if (eligible.includes(id)) dropTarget(row,card=>card.assignable,{action:'assign',peer:id});
|
|
@@ -1673,6 +1674,7 @@ function render(snapshot) {
|
|
|
1673
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'));
|
|
1674
1675
|
if (peer.paused) more.append(el('p', peer.paused, 'meta'));
|
|
1675
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'));
|
|
1676
1678
|
if (typeof peer.modelSource === 'string' && peer.modelSource) more.append(el('p', 'Model source: ' + peer.modelSource, 'meta'));
|
|
1677
1679
|
for (const backend of peer.backends || []) {
|
|
1678
1680
|
if (backend.requestedModel) more.append(el('p', backend.kind.toUpperCase() + ': ' + (backend.actualModel || 'model not reported') + ' (' + backend.state + ')', 'meta'));
|