@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
|
@@ -41,6 +41,12 @@ export function conductorOperation(command:string,args:readonly string[]): Condu
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
/** Only a failed conductor/operational authority fence carries this marker, never an ordinary operation error. */
|
|
45
|
+
export class OperationalAuthorityError extends Error {
|
|
46
|
+
readonly code = "AHUB_OPERATIONAL_AUTHORITY";
|
|
47
|
+
constructor(message:string,readonly scope:"conductor"|"person"|"operational"="operational") { super(message); }
|
|
48
|
+
}
|
|
49
|
+
|
|
44
50
|
export const OPERATIONAL_PERSON_GUIDANCE="the person runs it in ahub console or a terminal";
|
|
45
51
|
export function operationalRefusal(peer:string,command:string):string {
|
|
46
52
|
return `${peer} cannot run ahub ${command}; ${OPERATIONAL_PERSON_GUIDANCE}; this operation requires the explicit conductor role`;
|
|
@@ -54,6 +60,7 @@ export function operationalRequestLabel(value:unknown):string|undefined {
|
|
|
54
60
|
return message.op==="orphans"?"doctor orphans kill":message.op==="logs"||message.op==="export"?message.op:"unknown";
|
|
55
61
|
}
|
|
56
62
|
if(message.t==="peer_stop")return "stop";
|
|
63
|
+
if(message.t==="task" && message.op==="hub_task_cancel")return "task cancel";
|
|
57
64
|
if(message.t==="queue" && typeof message.op==="string" && ["show","resolve","discard"].includes(message.op))return `queue ${message.op}`;
|
|
58
65
|
if(message.t==="settings_set"||message.t==="settings_preview")return "settings set";
|
|
59
66
|
return undefined;
|
|
@@ -87,16 +87,12 @@ const positive=(value:unknown):value is number=>Number.isSafeInteger(value) && (
|
|
|
87
87
|
|
|
88
88
|
/** Bind the existing task/privacy projections once per read; no private title or path leaves this closure. */
|
|
89
89
|
export function operationalView(ports:OperationalViewPorts) {
|
|
90
|
-
const
|
|
90
|
+
const publicTasks=new Set<number>();
|
|
91
91
|
const privateTitles:string[]=[];
|
|
92
|
-
//
|
|
93
|
-
let known=
|
|
94
|
-
try {for(const task of ports.tasks
|
|
95
|
-
const safeTask=(id:number):boolean=>
|
|
96
|
-
const task=tasks.get(id);
|
|
97
|
-
if (!task) return false;
|
|
98
|
-
try {return !ports.isPii(task) && ports.publicView(task).title===task.title;} catch {return false;}
|
|
99
|
-
};
|
|
92
|
+
// The caller caches this complete inventory by board/privacy revision; there is no task-count display ceiling.
|
|
93
|
+
let known=true;
|
|
94
|
+
try {for(const task of ports.tasks) {const view=ports.publicView(task);if(ports.isPii(task) || view.title!==task.title) privateTitles.push(task.title);else publicTasks.add(task.id);}} catch {known=false;}
|
|
95
|
+
const safeTask=(id:number):boolean=>known && publicTasks.has(id);
|
|
100
96
|
const safeText=(text:string):boolean=>{
|
|
101
97
|
if (!known || Buffer.byteLength(text)>65536 || privateTitles.some(title=>title && text.includes(title))) return false;
|
|
102
98
|
try {return ports.publicMetadataText(text);} catch {return false;}
|
|
@@ -137,5 +133,5 @@ export function operationalView(ports:OperationalViewPorts) {
|
|
|
137
133
|
return JSON.parse(text,(_key,item)=>typeof item==="string"?sanitize(item):item) as Record<string,unknown>;
|
|
138
134
|
} catch {return withheld;}
|
|
139
135
|
};
|
|
140
|
-
return {line,event,complete:known};
|
|
136
|
+
return {line,event,complete:known,...(!known?{incompleteReason:"privacy inventory unavailable"}: {})};
|
|
141
137
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { accessSync, constants, lstatSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
2
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
3
|
import { spawnSync } from "node:child_process";
|
|
5
4
|
import { createHash } from "node:crypto";
|
|
@@ -7,10 +6,11 @@ import { guardPath, type ShellExecutionCheck } from "../local/tools.ts";
|
|
|
7
6
|
import { hostExecutable } from "./host-executable.ts";
|
|
8
7
|
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
9
8
|
import { realPath } from "./project.ts";
|
|
10
|
-
import { readOnlyShellForm, type ReadOnlyProgram } from "./read-only-shell.ts";
|
|
9
|
+
import { parseSimpleShell, readOnlyShellForm, type ReadOnlyProgram } from "./read-only-shell.ts";
|
|
10
|
+
import { hostShellEnvironment } from "../local/shell-environment.ts";
|
|
11
11
|
|
|
12
12
|
export interface ShellProofOptions { root:string; projectRoot?:string; deny:string[]; readOnly?:string[]; env?:Record<string,string> }
|
|
13
|
-
export interface ReadOnlyShellProof { fingerprint:string; program:ReadOnlyProgram }
|
|
13
|
+
export interface ReadOnlyShellProof { fingerprint:string; program:ReadOnlyProgram; executable:string; argv:readonly string[] }
|
|
14
14
|
const within=(root:string,path:string)=>{const p=relative(root,path);return p==="" || p!==".."&&!p.startsWith("../")&&!isAbsolute(p);};
|
|
15
15
|
function firstProgram(name:string,root:string,path:string):string|undefined {
|
|
16
16
|
if(path.length>16384)return undefined;
|
|
@@ -49,14 +49,15 @@ export function proveReadOnlyShell(input:unknown,options:ShellProofOptions,curre
|
|
|
49
49
|
const root=realPath(options.root),projectRoot=realPath(options.projectRoot??options.root),names:string[]=[root,projectRoot];
|
|
50
50
|
const form=readOnlyShellForm(args.command,root,(path,base=root)=>{if(path.split("/").includes(".."))throw new Error("ambiguous parent traversal");const lexical=resolve(base,path),canonical=guardPath({cwd:root,deny:options.deny,readOnly:options.readOnly},lexical,"read");names.push(lexical,canonical);return canonical;});
|
|
51
51
|
if(!form || !current())return undefined;
|
|
52
|
-
const env=
|
|
52
|
+
const env=hostShellEnvironment(options.env);
|
|
53
53
|
const lexical=firstProgram(form.program,root,env.PATH);if(!lexical || within(root,lexical) || within(projectRoot,lexical))return undefined;
|
|
54
54
|
const executable=hostExecutable(form.program,root,env.PATH);if(realPath(lexical)!==executable || within(projectRoot,executable) || !current())return undefined;
|
|
55
55
|
if(form.gitCwd && !gitHelpersUnconfigured(executable,form.gitCwd,env,current))return undefined;
|
|
56
56
|
// ponytail: this screens the declared helper-bearing config keys and explicit diff/textconv flags; other config-mediated helpers need dedicated audited forms before extending the allowlist.
|
|
57
57
|
const paths=new Set([...names.flatMap(name=>ancestors(name,root)),...ancestors(lexical,"/"),...ancestors(executable,"/")]);
|
|
58
|
-
const fingerprint=createHash("sha256").update(JSON.stringify([root,projectRoot,args.command,env
|
|
59
|
-
|
|
58
|
+
const fingerprint=createHash("sha256").update(JSON.stringify([root,projectRoot,args.command,env,lexical,executable,[...paths].map(path=>stamp(path,path===lexical||path===executable))])).digest("hex");
|
|
59
|
+
const argv=parseSimpleShell(args.command)!.words.slice(1);
|
|
60
|
+
return current()?{fingerprint,program:form.program,executable,argv}:undefined;
|
|
60
61
|
} catch {return undefined;}
|
|
61
62
|
}
|
|
62
63
|
interface Decision { command:string; current:()=>boolean; proof?:ReadOnlyShellProof }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #852: a reviewer's review scope. `full` reads the code, runs the affected tests and reads CI; `code` reads the code only, because its sandbox
|
|
3
|
+
* cannot run tests or read CI. AC3 (amended 2026-10-26) is structural, never a reading of free text: a `code` reviewer's changes_requested is always
|
|
4
|
+
* recorded, and one with no open structured finding is recorded as evidence-only (see Tasks.review).
|
|
5
|
+
*/
|
|
6
|
+
export type ReviewScope = "full" | "code";
|
|
7
|
+
export const REVIEW_SCOPES: readonly ReviewScope[] = ["full", "code"];
|
|
8
|
+
export const isReviewScope = (value: unknown): value is ReviewScope => value === "full" || value === "code";
|
|
9
|
+
|
|
10
|
+
/** The one review-request line for a `code` reviewer (AC2). Fixed text: no hub or PR content is interpolated. */
|
|
11
|
+
export const CODE_SCOPE_LINE = "Your sandbox cannot run tests or read CI: review the code against the plan. The merger gates on exact-head CI (#852). Findings are only for defects in the reviewed code, docs or tests. A missing check result, CI result, test run or PR/branch identity is an evidence request: put it in unmet, never in findings. Report every code defect as a structured finding with an id (hub_review findings, disposition open); a verdict with no open finding is recorded as evidence-only and does not count as a rejection.";
|
|
12
|
+
/** The one task notice the conductor gets when a code-scope reviewer's verdict is recorded as evidence-only (ids only, never text). */
|
|
13
|
+
export const evidenceOnlyNotice = (task: number, reviewer: string): string => `Task #${task}: a code-scope review by ${reviewer} was recorded as evidence-only (no open finding) and the task returned to its owner. Take the review.`;
|
package/src/hub/sdlc-metrics.ts
CHANGED
|
@@ -11,8 +11,8 @@ export type BandTier = typeof BAND_TIERS[number];
|
|
|
11
11
|
export interface MetricTask {
|
|
12
12
|
id: number;
|
|
13
13
|
private?: boolean;
|
|
14
|
-
/** `requestHead` is the head a `done` asked review for (reviewCost.head); `verdictHead` the head a verdict was bound to (reviewFindings.head). Compared for equality only, never printed. */
|
|
15
|
-
history: { at: number; event: string; requestHead?: string; verdictHead?: string }[];
|
|
14
|
+
/** `evidenceOnly` marks a code-scope verdict with no open finding (#852): it is not a rework round. `requestHead` is the head a `done` asked review for (reviewCost.head); `verdictHead` the head a verdict was bound to (reviewFindings.head). Compared for equality only, never printed. */
|
|
15
|
+
history: { at: number; event: string; requestHead?: string; verdictHead?: string; evidenceOnly?: true }[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Per-task values; `null` is unknown. */
|
|
@@ -98,6 +98,8 @@ export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciRerun
|
|
|
98
98
|
const spec = first(view, ["spec accepted"]);
|
|
99
99
|
const plan = spec ? first(view, ["plan reviewed"], spec.at) : undefined;
|
|
100
100
|
const verdict = accepted ? first(view, VERDICTS, accepted.at) : first(view, VERDICTS);
|
|
101
|
+
// Evidence-only still ends the actual wait for a first review, but says nothing about whether the work passed.
|
|
102
|
+
const substantiveVerdict = history.find(entry => entry.at >= (accepted?.at ?? -Infinity) && VERDICTS.includes(entry.event) && entry.evidenceOnly !== true);
|
|
101
103
|
const dones = history.filter(entry => entry.event === "done");
|
|
102
104
|
const ci = first(view, ["check passed", "check failed"]);
|
|
103
105
|
const approvedAt = history.find(entry => entry.event === "approved")?.at ?? null;
|
|
@@ -108,10 +110,10 @@ export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciRerun
|
|
|
108
110
|
acceptance_to_plan_ms: spec && plan ? plan.at - spec.at : null,
|
|
109
111
|
acceptance_to_first_review_ms: accepted && verdict ? verdict.at - accepted.at : null,
|
|
110
112
|
acceptance_to_merge_ms: accepted && extra.mergedAt !== undefined && Number.isFinite(extra.mergedAt) && extra.mergedAt >= accepted.at ? extra.mergedAt - accepted.at : null,
|
|
111
|
-
first_pass_approval:
|
|
113
|
+
first_pass_approval: substantiveVerdict ? (substantiveVerdict.event === "approved" ? 1 : 0) : null,
|
|
112
114
|
// Rework grows with every round, so it is a value only once the task is approved: a task still in flight has no final one, and a
|
|
113
115
|
// provisional point would move at each verdict and could start a second breach episode inside one shift.
|
|
114
|
-
rework_rounds: dones.length && approvedAt !== null ? history.filter(entry => entry.event === "changes_requested").length : null,
|
|
116
|
+
rework_rounds: dones.length && approvedAt !== null ? history.filter(entry => entry.event === "changes_requested" && entry.evidenceOnly !== true).length : null,
|
|
115
117
|
first_pass_check: ci ? (ci.event === "check passed" ? 1 : 0) : null,
|
|
116
118
|
same_head_ci_reruns: extra.ciReruns === undefined ? null : extra.ciReruns,
|
|
117
119
|
stale_head_re_reviews: approvedAt !== null ? staleHeadRounds(history) : null,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { parseSimpleShell } from "./read-only-shell.ts";
|
|
2
|
+
|
|
3
|
+
/** Short branch names from host evidence, never from the permission request. */
|
|
4
|
+
export interface TaskShellBinding { branch: string; defaultBranch: string }
|
|
5
|
+
export interface TaskShellCommand {
|
|
6
|
+
kind: "check" | "build" | "test" | "add" | "commit" | "push" | "pr-view" | "pr-checks" | "pr-create";
|
|
7
|
+
program: "scripts/check.sh" | "bun" | "git" | "gh";
|
|
8
|
+
argv: readonly string[];
|
|
9
|
+
/** Original literal path operands. The host must guard them under its verified execution root. */
|
|
10
|
+
paths: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function shortBranch(value: unknown): value is string {
|
|
14
|
+
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(value)
|
|
15
|
+
&& value !== "HEAD" && !value.startsWith("refs/") && !value.includes("..") && !value.includes("//")
|
|
16
|
+
&& !value.endsWith("/") && !value.endsWith(".")
|
|
17
|
+
&& value.split("/").every(part => !part.startsWith(".") && !part.endsWith(".lock"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function literalPath(value: string): boolean {
|
|
21
|
+
if (!value || value.length > 512 || value.startsWith("-") || !/^[A-Za-z0-9_./-]+$/.test(value)) return false;
|
|
22
|
+
const relative = value.startsWith("./") ? value.slice(2) : value;
|
|
23
|
+
return !relative.startsWith("-") && relative.split("/").every(part => !!part && part !== "." && part !== ".."
|
|
24
|
+
&& ![".git", ".agenthub"].includes(part.toLowerCase()));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const data = (value: string | undefined, limit: number): value is string =>
|
|
28
|
+
typeof value === "string" && value.length <= limit && !!value.trim() && !/[\p{Cc}\p{Cf}]/u.test(value);
|
|
29
|
+
|
|
30
|
+
export function taskShellBindingValid(binding: TaskShellBinding): boolean {
|
|
31
|
+
return !!binding && shortBranch(binding.branch) && shortBranch(binding.defaultBranch)
|
|
32
|
+
&& ![binding.defaultBranch.toLowerCase(), "main", "master"].includes(binding.branch.toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Lexical data only: this does not authorize execution, bind a task, resolve a program or read Git/files.
|
|
37
|
+
* The caller supplies authoritative native cwd/environment, current child/task/worktree/branch evidence and
|
|
38
|
+
* action/privacy policy, then guards every returned path and executable before answering the native prompt.
|
|
39
|
+
* Quoted punctuation is data only in commit messages and PR title/body values; path/ref operands stay plain.
|
|
40
|
+
*/
|
|
41
|
+
export function taskShellCommand(command: unknown, binding: TaskShellBinding): TaskShellCommand | undefined {
|
|
42
|
+
if (!taskShellBindingValid(binding)) return undefined;
|
|
43
|
+
const parsed = parseSimpleShell(command);
|
|
44
|
+
if (!parsed) return undefined;
|
|
45
|
+
const argv = parsed.words;
|
|
46
|
+
const result = (kind: TaskShellCommand["kind"], program: TaskShellCommand["program"], paths: readonly string[] = []): TaskShellCommand =>
|
|
47
|
+
({ kind, program, argv: [...argv], paths: [...paths] });
|
|
48
|
+
if (argv.length === 2 && argv[0] === "scripts/check.sh" && argv[1] === "--changed") return result("check", "scripts/check.sh", ["scripts/check.sh"]);
|
|
49
|
+
if (argv[0] === "bun") {
|
|
50
|
+
if (argv.length === 3 && argv[1] === "run" && argv[2] === "build") return result("build", "bun");
|
|
51
|
+
const paths = argv.slice(2);
|
|
52
|
+
if (argv[1] === "test" && paths.length >= 1 && paths.length <= 16 && paths.every(path => {
|
|
53
|
+
const relative = path.startsWith("./") ? path.slice(2) : path;
|
|
54
|
+
return literalPath(path) && relative.startsWith("test/") && /^[A-Za-z0-9_][A-Za-z0-9_.-]*\.test\.ts$/.test(relative.split("/").at(-1)!);
|
|
55
|
+
})) return result("test", "bun", paths);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
if (argv[0] === "git") {
|
|
59
|
+
if (argv[1] === "add") {
|
|
60
|
+
const paths = argv.slice(argv[2] === "--" ? 3 : 2);
|
|
61
|
+
if (paths.length >= 1 && paths.length <= 64 && paths.every(literalPath)) return result("add", "git", paths);
|
|
62
|
+
}
|
|
63
|
+
if (argv.length === 4 && argv[1] === "commit" && argv[2] === "-m" && data(argv[3], 1000)) return result("commit", "git");
|
|
64
|
+
if (argv[1] === "push" && (argv.length === 4 && argv[2] === "origin" && argv[3] === binding.branch
|
|
65
|
+
|| argv.length === 5 && argv[2] === "-u" && argv[3] === "origin" && argv[4] === binding.branch)) return result("push", "git");
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
if (argv[0] !== "gh" || argv[1] !== "pr") return undefined;
|
|
69
|
+
if (argv.length === 4 && argv[3] === binding.branch) {
|
|
70
|
+
if (argv[2] === "view") return result("pr-view", "gh");
|
|
71
|
+
if (argv[2] === "checks") return result("pr-checks", "gh");
|
|
72
|
+
}
|
|
73
|
+
if (argv[2] !== "create" || argv.length !== 11) return undefined;
|
|
74
|
+
const allowed = new Set(["--base", "--head", "--title", "--body", "--body-file"]), flags = new Map<string, string>();
|
|
75
|
+
for (let index = 3; index < argv.length; index += 2) {
|
|
76
|
+
const flag = argv[index]!, value = argv[index + 1];
|
|
77
|
+
if (!allowed.has(flag) || value === undefined || flags.has(flag)) return undefined;
|
|
78
|
+
flags.set(flag, value);
|
|
79
|
+
}
|
|
80
|
+
if (flags.get("--base") !== binding.defaultBranch || flags.get("--head") !== binding.branch || !data(flags.get("--title"), 1000)) return undefined;
|
|
81
|
+
if (flags.has("--body") === flags.has("--body-file")) return undefined;
|
|
82
|
+
if (flags.has("--body")) return data(flags.get("--body"), 3000) ? result("pr-create", "gh") : undefined;
|
|
83
|
+
const file = flags.get("--body-file")!;
|
|
84
|
+
return literalPath(file) ? result("pr-create", "gh", [file]) : undefined;
|
|
85
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { chmodSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { stopOwnedProcess, trackGroup } from "./child-process.ts";
|
|
5
|
+
import { hostExecutable } from "./host-executable.ts";
|
|
6
|
+
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
7
|
+
import { realPath } from "./project.ts";
|
|
8
|
+
import { isGitHubRepository } from "./spec-read.ts";
|
|
9
|
+
import { shortBranch } from "./task-shell-command.ts";
|
|
10
|
+
|
|
11
|
+
const OUTPUT_LIMIT = 64 * 1024;
|
|
12
|
+
function remoteHead(bytes: Buffer): string | undefined {
|
|
13
|
+
const output = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
14
|
+
if (!output.endsWith("\n")) return;
|
|
15
|
+
const lines = output.slice(0, -1).split("\n");
|
|
16
|
+
if (lines.length !== 2) return;
|
|
17
|
+
const symbolic = lines.filter(line => /^ref: refs\/heads\/.+\tHEAD$/.test(line));
|
|
18
|
+
const oid = lines.filter(line => /^[0-9a-f]{40}\tHEAD$/.test(line));
|
|
19
|
+
if (symbolic.length !== 1 || oid.length !== 1) return;
|
|
20
|
+
const branch = symbolic[0]!.slice("ref: refs/heads/".length, -"\tHEAD".length);
|
|
21
|
+
return shortBranch(branch) ? branch : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Startup authority comes from the fixed GitHub remote, never a mutable local origin/HEAD or repository config.
|
|
25
|
+
* No task/native environment or account credentials are inherited. Unknown remote evidence keeps automatic execution unavailable. */
|
|
26
|
+
export async function readTaskShellDefaultBranch(root: string, env: Record<string, string>, current: () => boolean, projectRoot: string, repository?: string, timeoutMs = 5000): Promise<string | undefined> {
|
|
27
|
+
let temporary: string | undefined, proc: ChildProcess | undefined;
|
|
28
|
+
try {
|
|
29
|
+
if (!isGitHubRepository(repository) || !current() || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000
|
|
30
|
+
|| !env.PATH || env.PATH.length > 16384 || env.PATH.split(":").some(dir => !isAbsolute(dir) || dir.split("/").includes(".."))) return;
|
|
31
|
+
root = realPath(root);
|
|
32
|
+
const project = realPath(projectRoot), executable = hostExecutable("git", root, env.PATH), rel = relative(project, executable);
|
|
33
|
+
if (!rel || rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel) || !lstatSync(executable).isFile()) return;
|
|
34
|
+
temporary = realPath(mkdtempSync(join(realPath("/tmp"), "ahub-default-"))); chmodSync(temporary, 0o700);
|
|
35
|
+
const own = temporary, stat = lstatSync(own), identity = JSON.stringify([stat.dev, stat.ino, stat.mode]);
|
|
36
|
+
const remove = () => {
|
|
37
|
+
const held = lstatSync(own);
|
|
38
|
+
if (!held.isDirectory() || realPath(own) !== own || JSON.stringify([held.dev, held.ino, held.mode]) !== identity) throw new Error("unavailable");
|
|
39
|
+
rmSync(own, { recursive: true, force: true }); temporary = undefined;
|
|
40
|
+
};
|
|
41
|
+
const environment = hubGitEnv({ PATH: env.PATH, HOME: own, XDG_CONFIG_HOME: own, LANG: env.LANG ?? "C", TERM: "dumb", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CEILING_DIRECTORIES: own }, { repositoryIsolation: true });
|
|
42
|
+
const argv = hubGitArgv(["-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "ls-remote", "--symref", "--exit-code", `https://github.com/${repository}.git`, "HEAD"]);
|
|
43
|
+
if (!current()) { remove(); return; }
|
|
44
|
+
return await new Promise<string | undefined>(resolve => {
|
|
45
|
+
let done = false, bytes = 0, overflowed = false;
|
|
46
|
+
const output: Buffer[] = [];
|
|
47
|
+
let timer: ReturnType<typeof setTimeout> | undefined, watcher: ReturnType<typeof setInterval> | undefined;
|
|
48
|
+
const finish = async (code: number | null) => {
|
|
49
|
+
if (done) return; done = true; clearTimeout(timer); clearInterval(watcher);
|
|
50
|
+
// Do not remove a private cwd/HOME while an owned group may still use it. The existing helper refuses unknown ownership.
|
|
51
|
+
try { if (proc) await stopOwnedProcess(proc, { group: true }); remove(); }
|
|
52
|
+
catch { resolve(undefined); return; }
|
|
53
|
+
try { resolve(code === 0 && !overflowed && current() ? remoteHead(Buffer.concat(output)) : undefined); }
|
|
54
|
+
catch { resolve(undefined); }
|
|
55
|
+
};
|
|
56
|
+
try { proc = spawn(executable, argv.slice(1), { cwd: own, env: environment, detached: true, stdio: ["ignore", "pipe", "pipe"] }); }
|
|
57
|
+
catch { void finish(null); return; }
|
|
58
|
+
timer = setTimeout(() => void finish(null), timeoutMs);
|
|
59
|
+
watcher = setInterval(() => { try { if (!current()) void finish(null); } catch { void finish(null); } }, 50); watcher.unref();
|
|
60
|
+
proc.once("spawn", () => trackGroup(proc!));
|
|
61
|
+
const take = (chunk: Buffer, stdout: boolean) => {
|
|
62
|
+
if (done) return;
|
|
63
|
+
bytes += chunk.length;
|
|
64
|
+
if (bytes > OUTPUT_LIMIT) { overflowed = true; void finish(null); return; }
|
|
65
|
+
if (stdout) output.push(Buffer.from(chunk));
|
|
66
|
+
};
|
|
67
|
+
proc.stdout!.on("data", (chunk: Buffer) => take(chunk, true));
|
|
68
|
+
proc.stderr!.on("data", (chunk: Buffer) => take(chunk, false));
|
|
69
|
+
proc.once("error", () => void finish(null));
|
|
70
|
+
proc.once("exit", code => { if (code !== 0) void finish(null); });
|
|
71
|
+
proc.once("close", code => void finish(code));
|
|
72
|
+
});
|
|
73
|
+
} catch {
|
|
74
|
+
// A spawn/setup failure owns no live group; otherwise only confirmed stop above permits removing the private directory.
|
|
75
|
+
if (!proc && temporary) { try { rmSync(temporary, { recursive: true, force: true }); } catch { /* unknown cleanup remains private */ } }
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { accessSync, closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { guardPath } from "../local/tools.ts";
|
|
6
|
+
import { hostExecutable } from "./host-executable.ts";
|
|
7
|
+
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
8
|
+
import { grantablePath } from "./permission-mode.ts";
|
|
9
|
+
import { realPath } from "./project.ts";
|
|
10
|
+
import { taskShellBindingValid, taskShellCommand, type TaskShellBinding, type TaskShellCommand } from "./task-shell-command.ts";
|
|
11
|
+
|
|
12
|
+
const within = (root: string, path: string) => { const rel = relative(root, path); return !rel || rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel); };
|
|
13
|
+
const stamp = (path: string) => { const s = lstatSync(path, { throwIfNoEntry: false }); return s ? [path, s.dev, s.ino, s.mode, s.size, s.mtimeMs, s.ctimeMs] : [path, "missing"]; };
|
|
14
|
+
|
|
15
|
+
/** One bounded synchronous metadata read. No network, ambient repository selectors or repository helper execution.
|
|
16
|
+
* ponytail: each reader's synchronous Git burst can block the hub for up to 1.5 seconds; move to asynchronous reads with one shared proof budget. */
|
|
17
|
+
function gitReader(root: string, env: Record<string, string>, current: () => boolean, projectRoot = root) {
|
|
18
|
+
if (!env.PATH || env.PATH.length > 16384 || env.PATH.split(":").some(dir => !isAbsolute(dir) || dir.split("/").includes(".."))) throw new Error("unavailable");
|
|
19
|
+
const executable = hostExecutable("git", root, env.PATH), deadline = performance.now() + 1500;
|
|
20
|
+
if (within(realPath(projectRoot), executable)) throw new Error("unavailable");
|
|
21
|
+
return (args: string[], absent = false): string => {
|
|
22
|
+
const left = Math.floor(deadline - performance.now());
|
|
23
|
+
if (!current() || left <= 0) throw new Error("unavailable");
|
|
24
|
+
const result = spawnSync(executable, hubGitArgv(["-C", root, ...args]).slice(1), {
|
|
25
|
+
cwd: root, env: hubGitEnv(env, { repositoryIsolation: true }), encoding: "utf8", timeout: left, maxBuffer: 64 * 1024,
|
|
26
|
+
});
|
|
27
|
+
if (!current() || result.error || result.status !== 0 && !(absent && result.status === 1)) throw new Error("unavailable");
|
|
28
|
+
return result.stdout;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { readTaskShellDefaultBranch } from "./task-shell-default.ts";
|
|
33
|
+
|
|
34
|
+
/** Attached HEAD is live evidence; the default branch must be pinned by the host before task execution. */
|
|
35
|
+
export function readTaskShellBinding(root: string, env: Record<string, string>, current: () => boolean, projectRoot = root, defaultBranch?: string): TaskShellBinding | undefined {
|
|
36
|
+
try {
|
|
37
|
+
if (!defaultBranch) return;
|
|
38
|
+
const git = gitReader(realPath(root), env, current, projectRoot);
|
|
39
|
+
if (realPath(git(["rev-parse", "--show-toplevel"]).replace(/\n$/, "")) !== realPath(root)) return;
|
|
40
|
+
const branch = git(["symbolic-ref", "--quiet", "--short", "HEAD"]).replace(/\n$/, "");
|
|
41
|
+
const binding = { branch, defaultBranch };
|
|
42
|
+
return taskShellBindingValid(binding) && current() ? binding : undefined;
|
|
43
|
+
} catch { return undefined; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TaskShellProofOptions {
|
|
47
|
+
root: string; projectRoot: string; deny: string[]; readOnly?: string[]; protectInstructions: boolean;
|
|
48
|
+
binding: TaskShellBinding; env: Record<string, string>; repository?: string;
|
|
49
|
+
/** Host privacy screen for the captured PR body. Missing or unknown screening never grants a body-file upload. */
|
|
50
|
+
publicText?: (text: string) => boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface TaskShellProof {
|
|
53
|
+
fingerprint: string; program: TaskShellCommand["program"]; executable: string; argv: readonly string[];
|
|
54
|
+
env: Readonly<Record<string, string>>; requiresSandbox: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Capture only a bounded regular file; the privileged CLI receives these screened bytes, never a path it can reopen later. */
|
|
58
|
+
function bodyFileText(file: string): string {
|
|
59
|
+
const initial = lstatSync(file);
|
|
60
|
+
if (!initial.isFile() || initial.size > 12000) throw new Error("unavailable");
|
|
61
|
+
const before = stamp(file), fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
62
|
+
try {
|
|
63
|
+
const stat = fstatSync(fd);
|
|
64
|
+
if (!stat.isFile() || stat.size > 12000 || JSON.stringify(before) !== JSON.stringify([file, stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs])) throw new Error("unavailable");
|
|
65
|
+
const bytes = Buffer.alloc(12001), count = readSync(fd, bytes, 0, bytes.length, 0), after = fstatSync(fd);
|
|
66
|
+
if (count !== stat.size || JSON.stringify(stamp(file)) !== JSON.stringify(before)
|
|
67
|
+
|| [after.dev, after.ino, after.mode, after.size, after.mtimeMs, after.ctimeMs].some((value, index) => value !== [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs][index])) throw new Error("unavailable");
|
|
68
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, count));
|
|
69
|
+
if (!text.trim() || text.length > 3000 || text.includes("\0")) throw new Error("unavailable");
|
|
70
|
+
return text;
|
|
71
|
+
} finally { closeSync(fd); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Reuse the core's command-line hardening as environment config for Git children spawned internally by gh. */
|
|
75
|
+
function gitChildEnvironment(env: Record<string, string>): Record<string, string> {
|
|
76
|
+
const result = Object.fromEntries(Object.entries(hubGitEnv(env, { repositoryIsolation: true })).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
|
77
|
+
const argv = hubGitArgv([]).slice(1), configs: string[] = [];
|
|
78
|
+
for (let index = 0; index < argv.length; index++) {
|
|
79
|
+
if (argv[index] === "--no-pager") continue;
|
|
80
|
+
if (argv[index] !== "-c" || !argv[index + 1]?.includes("=")) throw new Error("unavailable");
|
|
81
|
+
configs.push(argv[++index]!);
|
|
82
|
+
}
|
|
83
|
+
result.GIT_CONFIG_COUNT = String(configs.length);
|
|
84
|
+
configs.forEach((config, index) => {
|
|
85
|
+
const split = config.indexOf("=");
|
|
86
|
+
result[`GIT_CONFIG_KEY_${index}`] = config.slice(0, split);
|
|
87
|
+
result[`GIT_CONFIG_VALUE_${index}`] = config.slice(split + 1);
|
|
88
|
+
});
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Host task-command proof only; read-only commands continue to use the existing #816 proof. */
|
|
93
|
+
export function proveTaskShell(command: unknown, options: TaskShellProofOptions, current: () => boolean): TaskShellProof | undefined {
|
|
94
|
+
try {
|
|
95
|
+
if (!current() || options.readOnly?.length) return;
|
|
96
|
+
const form = taskShellCommand(command, options.binding);
|
|
97
|
+
if (!form) return;
|
|
98
|
+
const root = realPath(options.root), project = realPath(options.projectRoot), env = options.env;
|
|
99
|
+
if (!env.PATH || env.PATH.length > 16384 || env.PATH.split(":").some(dir => !isAbsolute(dir) || dir.split("/").includes(".."))) return;
|
|
100
|
+
const binding = readTaskShellBinding(root, env, current, project, options.binding.defaultBranch);
|
|
101
|
+
if (!binding || binding.branch !== options.binding.branch || binding.defaultBranch !== options.binding.defaultBranch) return;
|
|
102
|
+
const paths = new Set<string>();
|
|
103
|
+
const path = (value: string, writing = false) => {
|
|
104
|
+
const target = guardPath({ cwd: root, deny: options.deny, readOnly: options.readOnly }, value, "read");
|
|
105
|
+
if (writing && !grantablePath(root, target, value, options.protectInstructions)) throw new Error("unavailable");
|
|
106
|
+
paths.add(resolve(root, value)); paths.add(target); return target;
|
|
107
|
+
};
|
|
108
|
+
for (const value of form.paths) path(value, form.kind === "add");
|
|
109
|
+
const executable = form.program === "scripts/check.sh" ? path("scripts/check.sh") : hostExecutable(form.program, root, env.PATH);
|
|
110
|
+
if (form.program !== "scripts/check.sh" && within(project, executable)) return;
|
|
111
|
+
accessSync(executable, constants.X_OK);
|
|
112
|
+
if (!lstatSync(executable).isFile()) return;
|
|
113
|
+
paths.add(executable);
|
|
114
|
+
let argv = [...form.argv.slice(1)], repository: string | undefined;
|
|
115
|
+
let effectiveEnv: Record<string, string> = { ...env };
|
|
116
|
+
let bodyFileIndex = -1;
|
|
117
|
+
if (form.kind === "pr-create") for (let index = 3; index < form.argv.length; index += 2) {
|
|
118
|
+
if (form.argv[index] === "--body-file") bodyFileIndex = index - 1; // Effective argv excludes the program.
|
|
119
|
+
}
|
|
120
|
+
if (bodyFileIndex >= 0) {
|
|
121
|
+
if (!options.publicText) return;
|
|
122
|
+
const body = bodyFileText(path(argv[bodyFileIndex + 1]!));
|
|
123
|
+
if (options.publicText(body) !== true) return;
|
|
124
|
+
argv.splice(bodyFileIndex, 2, "--body", body);
|
|
125
|
+
}
|
|
126
|
+
if (form.program === "git" || form.program === "gh") {
|
|
127
|
+
const git = gitReader(root, env, current, project);
|
|
128
|
+
// Includes can change config between proof and execution; partial clones can trigger implicit helper/network activity.
|
|
129
|
+
if (git(["config", "--get-regexp", "^(include\\.path|includeif\\..*\\.path|extensions\\.partialclone|remote\\..*\\.promisor)$"], true).trim()) return;
|
|
130
|
+
// Only add/commit can run attribute-selected clean/smudge or content-diff drivers. Push/gh must keep working with a global LFS install.
|
|
131
|
+
if ((form.kind === "add" || form.kind === "commit") && git(["config", "--get-regexp", "^(filter\\..*\\.(clean|smudge|process)|diff\\..*\\.(command|textconv))$"], true).trim()) return;
|
|
132
|
+
if (form.kind === "add") {
|
|
133
|
+
const selected = git(["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", ...form.paths]).split("\0").filter(Boolean);
|
|
134
|
+
if (selected.length > 256) return;
|
|
135
|
+
for (const value of selected) path(value, true);
|
|
136
|
+
}
|
|
137
|
+
if (form.kind === "commit") {
|
|
138
|
+
const staged = git(["diff", "--no-ext-diff", "--no-textconv", "--cached", "--name-only", "-z"]).split("\0").filter(Boolean);
|
|
139
|
+
if (staged.length > 256) return;
|
|
140
|
+
for (const value of staged) path(value, true);
|
|
141
|
+
}
|
|
142
|
+
if (form.kind === "push" || form.program === "gh") {
|
|
143
|
+
const expected = options.repository?.toLowerCase();
|
|
144
|
+
if (!expected || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(expected)) return;
|
|
145
|
+
const urls = [...git(["remote", "get-url", "--push", "--all", "origin"]).trim().split("\n"), ...git(["remote", "get-url", "--all", "origin"]).trim().split("\n")];
|
|
146
|
+
if (!urls.length || urls.length > 16 || urls.some(url => {
|
|
147
|
+
const match = /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(url);
|
|
148
|
+
return match?.[1]?.toLowerCase() !== expected;
|
|
149
|
+
})) return;
|
|
150
|
+
// No remote-selected helper or implicit extra push effect is part of the closed command shape.
|
|
151
|
+
if (git(["config", "--get-regexp", "^(core\\.(sshcommand|gitproxy)|remote\\.origin\\.(vcs|proxy|receivepack|push|mirror)|push\\.pushoption)$"], true).trim()) return;
|
|
152
|
+
repository = expected;
|
|
153
|
+
}
|
|
154
|
+
if (form.kind === "push") argv[argv.length - 1] = `refs/heads/${binding.branch}:refs/heads/${binding.branch}`;
|
|
155
|
+
if (form.program === "git") {
|
|
156
|
+
effectiveEnv = Object.fromEntries(Object.entries(hubGitEnv(env, { repositoryIsolation: true })).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
|
157
|
+
argv = hubGitArgv([
|
|
158
|
+
"-c", "commit.gpgSign=false", "-c", "push.gpgSign=false", "-c", "gc.auto=0", "-c", "maintenance.auto=false",
|
|
159
|
+
...(form.kind === "push" ? ["-c", "push.followTags=false", "-c", "remote.origin.mirror=false", "-c", "push.recurseSubmodules=no"] : []), ...argv,
|
|
160
|
+
]).slice(1);
|
|
161
|
+
} else {
|
|
162
|
+
effectiveEnv = gitChildEnvironment(env);
|
|
163
|
+
argv.push("--repo", `https://github.com/${repository}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const requiresSandbox = form.kind === "check" || form.program === "bun";
|
|
167
|
+
const rootStat = lstatSync(root);
|
|
168
|
+
const fingerprint = createHash("sha256").update(JSON.stringify([root, rootStat.dev, rootStat.ino, binding, effectiveEnv, executable, argv, repository, requiresSandbox, [...paths].map(stamp)])).digest("hex");
|
|
169
|
+
return current() ? { fingerprint, program: form.program, executable, argv: Object.freeze(argv), env: Object.freeze(effectiveEnv), requiresSandbox } : undefined;
|
|
170
|
+
} catch { return undefined; }
|
|
171
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { accessSync, constants, lstatSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { sbplString } from "../local/deny.ts";
|
|
5
|
+
import { hostExecutable } from "./host-executable.ts";
|
|
6
|
+
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
7
|
+
import { realPath } from "./project.ts";
|
|
8
|
+
import { taskShellBindingValid } from "./task-shell-command.ts";
|
|
9
|
+
|
|
10
|
+
export interface TaskShellSandboxOptions { projectRoot: string; env: Record<string, string> }
|
|
11
|
+
const inside = (root: string, file: string) => { const rel = relative(root, file); return !rel || rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel); };
|
|
12
|
+
const literal = (file: string) => `(literal ${sbplString(file)})`;
|
|
13
|
+
const subpath = (file: string) => `(subpath ${sbplString(file)})`;
|
|
14
|
+
|
|
15
|
+
/** Deny-only refinement of the worker profile, never an execution grant or a replacement sandbox.
|
|
16
|
+
* All Git metadata is immutable here, including objects: object content is later trusted by privileged Git operations.
|
|
17
|
+
* Unknown layouts (including a non-linked checkout) ask instead of guessing metadata boundaries.
|
|
18
|
+
* ponytail: synchronous Git evidence shares a 1.5 second burst; an async host proof is the upgrade path. */
|
|
19
|
+
export function taskShellSandboxRestrictions(root: string, branch: string, options: TaskShellSandboxOptions): string | undefined {
|
|
20
|
+
try {
|
|
21
|
+
if (!taskShellBindingValid({ branch, defaultBranch: "main" }) || !options.env.PATH || options.env.PATH.length > 16384
|
|
22
|
+
|| options.env.PATH.split(":").some(dir => !isAbsolute(dir) || dir.split("/").includes(".."))) return;
|
|
23
|
+
root = realPath(root);
|
|
24
|
+
const project = realPath(options.projectRoot), executable = hostExecutable("git", root, options.env.PATH);
|
|
25
|
+
if (inside(project, executable) || !lstatSync(executable).isFile()) return;
|
|
26
|
+
accessSync(executable, constants.X_OK);
|
|
27
|
+
const deadline = performance.now() + 1500;
|
|
28
|
+
const git = (...args: string[]) => {
|
|
29
|
+
const timeout = Math.floor(deadline - performance.now()); if (timeout <= 0) throw new Error("unavailable");
|
|
30
|
+
const result = spawnSync(executable, hubGitArgv(["-C", root, ...args]).slice(1), {
|
|
31
|
+
cwd: root, env: hubGitEnv(options.env, { repositoryIsolation: true }), encoding: "utf8", timeout, maxBuffer: 16384,
|
|
32
|
+
});
|
|
33
|
+
if (result.error || result.status !== 0 || !/^[^\r\n]+\n$/.test(result.stdout)) throw new Error("unavailable");
|
|
34
|
+
return result.stdout.slice(0, -1);
|
|
35
|
+
};
|
|
36
|
+
if (git("symbolic-ref", "--quiet", "--short", "HEAD") !== branch || realPath(git("rev-parse", "--show-toplevel")) !== root) return;
|
|
37
|
+
const gitDir = realPath(git("rev-parse", "--absolute-git-dir"));
|
|
38
|
+
const commonDir = realPath(resolve(root, git("rev-parse", "--git-common-dir")));
|
|
39
|
+
// Only a proven linked-worktree layout is supported; this refinement grants no metadata writes, even to the current worktree.
|
|
40
|
+
const worktreePart = relative(commonDir, gitDir).split("/");
|
|
41
|
+
if (worktreePart.length !== 2 || worktreePart[0] !== "worktrees" || !worktreePart[1] || gitDir === commonDir) return;
|
|
42
|
+
const entries = new Set<string>();
|
|
43
|
+
const directory = (file: string) => {
|
|
44
|
+
if (!lstatSync(file).isDirectory() || realPath(file) !== file || Buffer.byteLength(file) > 900 || /[\p{Cc}\p{Cf}]/u.test(file)) throw new Error("unavailable");
|
|
45
|
+
entries.add(file);
|
|
46
|
+
};
|
|
47
|
+
for (const metadata of [commonDir, gitDir]) directory(metadata);
|
|
48
|
+
for (let parent = dirname(gitDir); inside(commonDir, parent); parent = dirname(parent)) {
|
|
49
|
+
directory(parent); if (parent === commonDir) break;
|
|
50
|
+
}
|
|
51
|
+
// Protect metadata ancestors too when the metadata sits beneath the writable execution root.
|
|
52
|
+
for (const metadata of [commonDir, gitDir]) for (let parent = dirname(metadata); inside(root, parent); parent = dirname(parent)) {
|
|
53
|
+
directory(parent); if (parent === root) break;
|
|
54
|
+
}
|
|
55
|
+
return [
|
|
56
|
+
`(deny file-write* ${subpath(commonDir)} ${subpath(gitDir)})`,
|
|
57
|
+
// Exact metadata ancestor entries cannot be renamed or replaced either.
|
|
58
|
+
`(deny file-write* ${[...entries].sort().map(literal).join(" ")})`,
|
|
59
|
+
].join("\n");
|
|
60
|
+
} catch { return undefined; }
|
|
61
|
+
}
|