@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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { SettingDef } from "./settings.ts";
|
|
2
|
+
|
|
3
|
+
/** Existing live registry rows only. A new registry key never inherits conductor authority from a prefix. */
|
|
4
|
+
export const CONDUCTOR_SETTING_KEYS = [
|
|
5
|
+
"task_sweep.enabled", "task_sweep.auto_reassign", "task_sweep.no_writes_turns", "task_sweep.interval_s",
|
|
6
|
+
"task_sweep.unaccepted_min", "task_sweep.idle_min", "task_sweep.review_min", "task_sweep.ready_min", "task_sweep.ladder_min",
|
|
7
|
+
"snapshots.enabled", "research.enabled", "approvals.notify",
|
|
8
|
+
] as const;
|
|
9
|
+
const settingKeys: ReadonlySet<string> = new Set(CONDUCTOR_SETTING_KEYS);
|
|
10
|
+
export const operationalSettingKey = (value: unknown): value is string => typeof value === "string" && settingKeys.has(value);
|
|
11
|
+
|
|
12
|
+
/** Host registry identity must still match the exact live data leaf. This is policy, never proof of the caller's role. */
|
|
13
|
+
export function operationalSetting(def: Pick<SettingDef,"key"|"path"|"applies"|"store"|"locked">): boolean {
|
|
14
|
+
return !def.locked && def.applies==="live" && def.store==="config" && def.path.join(".")===def.key && operationalSettingKey(def.key);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Roster participation uses its existing lifecycle-aware write, independently of generic settings set. */
|
|
18
|
+
export function operationalRosterSetting(def: Pick<SettingDef,"key"|"path"|"applies"|"store"|"locked"|"group">): boolean {
|
|
19
|
+
return !def.locked && def.group==="Roster" && def.applies==="live" && def.store==="config"
|
|
20
|
+
&& def.path.length===2 && def.path[0]==="roster" && typeof def.path[1]==="string"
|
|
21
|
+
&& /^[a-z][a-z0-9-]{0,31}$/.test(def.path[1]) && def.key===`roster.${def.path[1]}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const CONDUCTOR_LOCAL_ACTIONS = ["doctor orphans kill","logs","export"] as const;
|
|
25
|
+
export type ConductorLocalAction = typeof CONDUCTOR_LOCAL_ACTIONS[number];
|
|
26
|
+
export const conductorLocalAction = (value:unknown): value is ConductorLocalAction => typeof value==="string" && (CONDUCTOR_LOCAL_ACTIONS as readonly string[]).includes(value);
|
|
27
|
+
export type ConductorOperation = ConductorLocalAction | "roster add" | "roster remove" | "stop" | "queue show" | "queue resolve" | "queue discard" | "task cancel" | "settings set";
|
|
28
|
+
|
|
29
|
+
/** Select the closed command family; the existing command parser still validates all arguments before effects. */
|
|
30
|
+
export function conductorOperation(command:string,args:readonly string[]): ConductorOperation | undefined {
|
|
31
|
+
const words=args.filter(arg=>!arg.startsWith("--"));
|
|
32
|
+
const sub=words[0];
|
|
33
|
+
if (command==="roster" && (sub==="add" || sub==="remove")) return `roster ${sub}`;
|
|
34
|
+
if (command==="stop" && args.length===1 && /^[a-z][a-z0-9-]{0,31}$/.test(args[0]!)) return "stop";
|
|
35
|
+
if (command==="queue" && (sub==="show" || sub==="resolve" || sub==="discard")) return `queue ${sub}`;
|
|
36
|
+
if (command==="task" && sub==="cancel") return "task cancel";
|
|
37
|
+
if (command==="doctor" && args.includes("--orphans") && args.includes("--kill")) return "doctor orphans kill";
|
|
38
|
+
if (command==="logs" || command==="export") return command;
|
|
39
|
+
if (command==="settings" && sub==="set" && operationalSettingKey(words[1])) return "settings set";
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
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
|
+
|
|
50
|
+
export const OPERATIONAL_PERSON_GUIDANCE="the person runs it in ahub console or a terminal";
|
|
51
|
+
export function operationalRefusal(peer:string,command:string):string {
|
|
52
|
+
return `${peer} cannot run ahub ${command}; ${OPERATIONAL_PERSON_GUIDANCE}; this operation requires the explicit conductor role`;
|
|
53
|
+
}
|
|
54
|
+
/** Fixed labels only; refused requests never put argument text in the audit. */
|
|
55
|
+
export function operationalRequestLabel(value:unknown):string|undefined {
|
|
56
|
+
if(!value || typeof value!=="object" || Array.isArray(value))return undefined;
|
|
57
|
+
const message=value as Record<string,unknown>;
|
|
58
|
+
if(message.t==="conductor_operational") {
|
|
59
|
+
if(message.op==="authorize")return typeof message.command==="string" && ["roster add","roster remove","stop","queue show","queue resolve","queue discard","task cancel","settings set","doctor orphans kill","logs","export"].includes(message.command)?message.command:"unknown";
|
|
60
|
+
return message.op==="orphans"?"doctor orphans kill":message.op==="logs"||message.op==="export"?message.op:"unknown";
|
|
61
|
+
}
|
|
62
|
+
if(message.t==="peer_stop")return "stop";
|
|
63
|
+
if(message.t==="task" && message.op==="hub_task_cancel")return "task cancel";
|
|
64
|
+
if(message.t==="queue" && typeof message.op==="string" && ["show","resolve","discard"].includes(message.op))return `queue ${message.op}`;
|
|
65
|
+
if(message.t==="settings_set"||message.t==="settings_preview")return "settings set";
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { sanitize } from "./envelope.ts";
|
|
2
|
+
import type { BusEvent } from "./bus.ts";
|
|
3
|
+
import { CLASSES, type Task } from "./board.ts";
|
|
4
|
+
|
|
5
|
+
export interface OperationalViewPorts {
|
|
6
|
+
tasks: readonly Task[];
|
|
7
|
+
isPii(task:Task):boolean;
|
|
8
|
+
publicView(task:Task):Record<string,unknown>;
|
|
9
|
+
publicMetadataText(text:string):boolean;
|
|
10
|
+
redact(record:unknown):unknown;
|
|
11
|
+
}
|
|
12
|
+
/** Closed metadata receipts only. Unsupported nested records are withheld until given their own projection. */
|
|
13
|
+
const EVENT_FIELDS: Record<string,readonly string[]>={
|
|
14
|
+
state:["peer","state"],hub_start:["instance"],native_thread:["peer","thread","fresh"],turn_start:["peer","turn"],native_turn_end:["peer","id"],
|
|
15
|
+
task:["id","event","by","state","owner","reviewer","class","profile","profileClass","role","pii","reason","release","priority","plannedStart","due"],
|
|
16
|
+
tokens:["peer","n","task","attribution","pii"],
|
|
17
|
+
usage:["peer","source","id","task","attribution","pii","reasoningTokens","measuredAt","inputTokens","outputTokens","cacheReadTokens","cacheWriteTokens","totalTokens"],
|
|
18
|
+
turn_end:["peer","turn","ms","tokens","files","snapshotMs","task","attribution","pii"],
|
|
19
|
+
conduct:["peer","action","task","target"],agent_cli:["peer","command","refused","delegated"],
|
|
20
|
+
pii_screen:["task","item","label","source","category","miss","ms"],
|
|
21
|
+
overflow:["id","from","peer"],undeliverable:["id","from","peer"],stale:["id","from","peer","task"],quiet:["id","from","peers"],
|
|
22
|
+
envelope:["id","from","to","priority","hop","kind","task","bytes","private","dropped"],
|
|
23
|
+
memory_brief:["source","peer","task","native","fails","claudeMem"],
|
|
24
|
+
fact:["peer","id","files","plans","unknown","named","bytes","via","ms","hookMs","rttMs","accepted","unanswered","probe","coverage"],
|
|
25
|
+
fact_ack:["peer","id","via","ms"],permission_mode:["peer","from","to"],
|
|
26
|
+
permission:["id","peer","event","latencyMs","surface","option"],
|
|
27
|
+
supervision_turn:["peer","turn","tokens","ms"],hook_stats:["peer","n","startupMs","hubMs","maxStartupMs"],
|
|
28
|
+
capability:["peer","state","via"],cohort:["id","event","silent","tasks","owners"],
|
|
29
|
+
planning:["by","task","release","fields","revision"],context_pressure:["peer","source","measuredAt","used","window"],
|
|
30
|
+
budget_pause:["peer","reason","window","resetsAt","by"],budget_resume:["peer","reason","window","resetsAt","by"],
|
|
31
|
+
quota_reset:["peer","window","cause","resetsAt","used","previousUsed","previousResetsAt","measuredAt"],
|
|
32
|
+
};
|
|
33
|
+
const EVENT_REQUIRED:Record<string,readonly string[]>={
|
|
34
|
+
state:["peer","state"],hub_start:["instance"],native_thread:["peer","thread","fresh"],turn_start:["peer","turn"],native_turn_end:["peer"],
|
|
35
|
+
task:["id","event","by","state","owner","reviewer","class","pii"],tokens:["peer","n"],usage:["peer","source","id"],turn_end:["peer","turn","ms"],
|
|
36
|
+
conduct:["peer","action"],agent_cli:["peer","command","refused"],pii_screen:["item","label","source"],
|
|
37
|
+
overflow:["id","from","peer"],undeliverable:["id","from","peer"],stale:["id","from","peer"],quiet:["id","from","peers"],envelope:["id","from","priority","hop"],
|
|
38
|
+
memory_brief:["peer","task","native","fails","claudeMem"],fact:["peer","id","files","plans","unknown","named","bytes","via"],fact_ack:["peer","id","via","ms"],
|
|
39
|
+
permission_mode:["peer","from","to"],permission:["id","peer","event"],supervision_turn:["peer","turn"],hook_stats:["peer","n","startupMs","hubMs","maxStartupMs"],
|
|
40
|
+
capability:["peer","state"],cohort:["id","event","silent","tasks","owners"],planning:["by","fields","revision"],context_pressure:["peer","source","measuredAt","used","window"],
|
|
41
|
+
budget_pause:["peer","reason","window","resetsAt","by"],budget_resume:["peer","reason","window","resetsAt","by"],quota_reset:["peer","window","cause","used","previousUsed"],
|
|
42
|
+
};
|
|
43
|
+
const NUMBERS=new Set(["n","ms","tokens","files","snapshotMs","reasoningTokens","inputTokens","outputTokens","cacheReadTokens","cacheWriteTokens","totalTokens","hop","bytes","native","fails","claudeMem","plans","unknown","named","hookMs","rttMs","latencyMs","startupMs","hubMs","maxStartupMs","used","previousUsed","previousResetsAt","resetsAt","plannedStart","due","revision"]);
|
|
44
|
+
const BOOLEANS=new Set(["pii","private","fresh","refused","delegated","accepted","unanswered","probe","coverage","silent"]);
|
|
45
|
+
const TASK_EVENTS=new Set(["proposed","backlogged","backlog pulled","backlog returned","accepted","assigned","reassigned","unassigned","reserved","ready","blocked","declined","escalated","done","approved","changes_requested","cancelled","lifecycle"]);
|
|
46
|
+
const identifier=(value:unknown):value is string=>typeof value==="string" && /^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,255}$/.test(value);
|
|
47
|
+
const peerId=(value:unknown):value is string=>typeof value==="string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
|
48
|
+
const oneOf=(value:unknown,values:readonly string[])=>typeof value==="string" && values.includes(value);
|
|
49
|
+
function metadataValue(type:string,key:string,value:unknown):boolean {
|
|
50
|
+
if(value===null && ["plannedStart","due"].includes(key))return true;
|
|
51
|
+
if(NUMBERS.has(key))return typeof value==="number" && Number.isFinite(value) && value>=0;
|
|
52
|
+
if(BOOLEANS.has(key))return typeof value==="boolean";
|
|
53
|
+
if(["peer","owner","reviewer","target","by"].includes(key))return value===null && ["owner","reviewer"].includes(key) || peerId(value);
|
|
54
|
+
if(key==="from" || key==="to")return type==="permission_mode"?oneOf(value,["ask","ask-when-needed","never-ask"]):key==="to"?Array.isArray(value)&&value.length<=64&&value.every(peerId):peerId(value);
|
|
55
|
+
if(["peers","owners"].includes(key))return Array.isArray(value)&&value.length<=64&&value.every(peerId);
|
|
56
|
+
if(key==="tasks")return Array.isArray(value)&&value.length<=500&&value.every(positive);
|
|
57
|
+
if(key==="task" || key==="release")return key==="release"&&value===null || positive(value) || key==="task"&&typeof value==="string"&&/^[1-9][0-9]*$/.test(value);
|
|
58
|
+
if(key==="id")return type==="task" || type==="cohort"?positive(value):identifier(value);
|
|
59
|
+
if(key==="class")return oneOf(value,CLASSES);
|
|
60
|
+
if(key==="profile")return oneOf(value,["dev","research","writing","general"]);
|
|
61
|
+
if(key==="profileClass")return typeof value==="string" && /^[a-z][a-z0-9_]{0,47}$/.test(value);
|
|
62
|
+
if(key==="state")return type==="task"?oneOf(value,["backlog","proposed","in_progress","in_review","approved","changes_requested","cancelled"]):type==="capability"?oneOf(value,["verified","lost"]):oneOf(value,["idle","busy","paused","offline"]);
|
|
63
|
+
if(key==="event")return type==="task"?typeof value==="string"&&TASK_EVENTS.has(value):type==="cohort"?oneOf(value,["formed","joined","lifted"]):oneOf(value,["requested","answered","expired","cancelled"]);
|
|
64
|
+
if(key==="attribution")return oneOf(value,["delivery","single_open","unattributed"]);
|
|
65
|
+
if(key==="label")return oneOf(value,["pii","clear"]);
|
|
66
|
+
if(key==="item")return oneOf(value,["task","summary","review note","handoff","note"]);
|
|
67
|
+
if(key==="category")return oneOf(value,["name","student_id","phone","address","grade","health","other"]);
|
|
68
|
+
if(key==="miss")return oneOf(value,["too long","off campus","timeout","unreadable","failed"]);
|
|
69
|
+
if(key==="source")return type==="pii_screen"?oneOf(value,["regex","screen","unknown","inherited"]):type==="usage"?oneOf(value,["omniroute","claude_transcript","claude_result","codex_appserver","kimi_wire"]):identifier(value);
|
|
70
|
+
if(key==="measuredAt")return type==="context_pressure" || type==="quota_reset"?typeof value==="number"&&Number.isFinite(value):typeof value==="string"&&Number.isFinite(Date.parse(value));
|
|
71
|
+
if(key==="window")return value===null || typeof value==="number"&&Number.isFinite(value)&&value>=0 || identifier(value);
|
|
72
|
+
if(key==="priority")return value===null || typeof value==="number"&&Number.isFinite(value) || oneOf(value,["status","important","fyi"]);
|
|
73
|
+
if(key==="fields")return Array.isArray(value)&&value.length<=32&&value.every(identifier);
|
|
74
|
+
if(["action","command","reason"].includes(key))return typeof value==="string" && /^[a-z][a-z0-9_ .:-]{0,63}$/.test(value);
|
|
75
|
+
return identifier(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Shared by the daemon's existing console stream and operational projection tests. */
|
|
79
|
+
export function redactBusEvent(e:BusEvent):BusEvent {
|
|
80
|
+
if (!("env" in e) || !e.env.private) return e;
|
|
81
|
+
const {refs,...env}=e.env;
|
|
82
|
+
const task=refs?.task && /^[0-9]+$/.test(refs.task)?refs.task:undefined;
|
|
83
|
+
return {...e,env:{...env,...(task?{refs:{task}}:{}),body:`[private${task?`: task #${task}, see ahub task show ${task}`:""}]`}};
|
|
84
|
+
}
|
|
85
|
+
const WITHHELD="[withheld: private or unclassifiable operational record]";
|
|
86
|
+
const positive=(value:unknown):value is number=>Number.isSafeInteger(value) && (value as number)>0;
|
|
87
|
+
|
|
88
|
+
/** Bind the existing task/privacy projections once per read; no private title or path leaves this closure. */
|
|
89
|
+
export function operationalView(ports:OperationalViewPorts) {
|
|
90
|
+
const publicTasks=new Set<number>();
|
|
91
|
+
const privateTitles:string[]=[];
|
|
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);
|
|
96
|
+
const safeText=(text:string):boolean=>{
|
|
97
|
+
if (!known || Buffer.byteLength(text)>65536 || privateTitles.some(title=>title && text.includes(title))) return false;
|
|
98
|
+
try {return ports.publicMetadataText(text);} catch {return false;}
|
|
99
|
+
};
|
|
100
|
+
const line=(value:unknown):string=>{
|
|
101
|
+
if (typeof value!=="string" || !safeText(value)) return WITHHELD;
|
|
102
|
+
for(const match of value.matchAll(/\btask\s+#?([1-9][0-9]*)\b/gi)) if(!safeTask(Number(match[1])))return WITHHELD;
|
|
103
|
+
return sanitize(value);
|
|
104
|
+
};
|
|
105
|
+
const event=(value:unknown):Record<string,unknown>=>{
|
|
106
|
+
const withheld={type:"withheld"};
|
|
107
|
+
try {
|
|
108
|
+
if (!value || typeof value!=="object" || Array.isArray(value)) return withheld;
|
|
109
|
+
const raw=value as Record<string,unknown>;
|
|
110
|
+
if(raw.v!==1 || typeof raw.at!=="string" || !Number.isFinite(Date.parse(raw.at)) || typeof raw.type!=="string")return withheld;
|
|
111
|
+
const fields=EVENT_FIELDS[raw.type];
|
|
112
|
+
if(!fields || !Object.hasOwn(EVENT_FIELDS,raw.type))return withheld;
|
|
113
|
+
const allowed=new Set(["v","at","type",...fields]);
|
|
114
|
+
if(Object.keys(raw).some(key=>!allowed.has(key)) || EVENT_REQUIRED[raw.type]!.some(key=>!Object.hasOwn(raw,key) || raw[key]===undefined))return withheld;
|
|
115
|
+
if(fields.some(key=>raw[key]!==undefined && !metadataValue(raw.type as string,key,raw[key])))return withheld;
|
|
116
|
+
if (raw.pii===true || raw.private===true) return withheld;
|
|
117
|
+
const ids:unknown[]=[];
|
|
118
|
+
if (raw.type==="task") ids.push(raw.id);
|
|
119
|
+
if (raw.task!==undefined) ids.push(typeof raw.task==="string" && /^[1-9][0-9]*$/.test(raw.task)?Number(raw.task):raw.task);
|
|
120
|
+
if (raw.type==="conflict") ids.push(raw.other);
|
|
121
|
+
if (raw.type==="overlap") {
|
|
122
|
+
if(!Array.isArray(raw.others))return withheld;
|
|
123
|
+
for(const other of raw.others) {if(!other || typeof other!=="object")return withheld;ids.push(other.task);}
|
|
124
|
+
}
|
|
125
|
+
if(raw.type==="cohort") {if(!Array.isArray(raw.tasks))return withheld;ids.push(...raw.tasks);}
|
|
126
|
+
if(ids.some(id=>!positive(id) || !safeTask(id)))return withheld;
|
|
127
|
+
const redacted=ports.redact(value);
|
|
128
|
+
if(!redacted || typeof redacted!=="object" || Array.isArray(redacted))return withheld;
|
|
129
|
+
const projected=Object.fromEntries(["v","at","type",...fields].filter(key=>Object.hasOwn(redacted,key)).map(key=>[key,(redacted as Record<string,unknown>)[key]]));
|
|
130
|
+
const text=JSON.stringify(projected);
|
|
131
|
+
if(!safeText(text))return withheld;
|
|
132
|
+
// Rebuild through JSON, then sanitize every rendered string, including metadata strings.
|
|
133
|
+
return JSON.parse(text,(_key,item)=>typeof item==="string"?sanitize(item):item) as Record<string,unknown>;
|
|
134
|
+
} catch {return withheld;}
|
|
135
|
+
};
|
|
136
|
+
return {line,event,complete:known,...(!known?{incompleteReason:"privacy inventory unavailable"}: {})};
|
|
137
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { processLiveness, type Liveness } from "../pi/process-signature.ts";
|
|
4
|
+
import type { Project } from "./registry.ts";
|
|
5
|
+
import type { ControlDescriptor } from "./control-client.ts";
|
|
6
|
+
|
|
7
|
+
export interface OrphanSelection { root:string; projectId:string; instanceId:string; pid:number; executable:string; programs:readonly string[] }
|
|
8
|
+
export interface SignedOrphan { pid:number; signature:string; root:string; projectId:string; instanceId:string; self:boolean }
|
|
9
|
+
export interface OrphanPorts {
|
|
10
|
+
rootState(root:string):"missing"|"present"|"unknown";
|
|
11
|
+
liveness(pid:number,signature:string):Liveness;
|
|
12
|
+
command(pid:number):string|undefined;
|
|
13
|
+
signal(pid:number,signal:"SIGTERM"|"SIGKILL"):void;
|
|
14
|
+
current():void;
|
|
15
|
+
now():number;
|
|
16
|
+
sleep(ms:number):Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
export const orphanProcessPorts: OrphanPorts = {
|
|
19
|
+
rootState(root) { try {lstatSync(root);return "present";} catch(error) {return ["ENOENT","ENOTDIR"].includes((error as NodeJS.ErrnoException).code??"")?"missing":"unknown";} },
|
|
20
|
+
liveness:processLiveness,
|
|
21
|
+
command(pid) {const r=spawnSync("/bin/ps",["-p",String(pid),"-o","command="],{encoding:"utf8",timeout:2000,maxBuffer:65536});return r.status===0?r.stdout.trim()||undefined:undefined;},
|
|
22
|
+
signal:(pid,signal)=>process.kill(pid,signal),current:()=>{throw new Error("conductor authority unavailable");},now:Date.now,sleep:ms=>Bun.sleep(ms),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Host-known Bun executable and hub entrypoint only; joined argv cannot prove paths containing whitespace. */
|
|
26
|
+
export function exactOrphanCommand(command:string|undefined,selection:Pick<OrphanSelection,"root"|"executable"|"programs">):boolean {
|
|
27
|
+
if (!command || [selection.root,selection.executable,...selection.programs].some(word=>/\s/.test(word))) return false;
|
|
28
|
+
const words=command.trim().split(/\s+/);
|
|
29
|
+
return words[0]===selection.executable && selection.programs.includes(words[1]??"") && words[2]==="--project"
|
|
30
|
+
&& words[3]===selection.root && words[4]==="daemon" && (words.length===5 || words.length===6 && words[5]==="--unattended");
|
|
31
|
+
}
|
|
32
|
+
function owns(candidate:SignedOrphan,selection:OrphanSelection,ports:OrphanPorts):boolean {
|
|
33
|
+
ports.current();
|
|
34
|
+
return candidate.root===selection.root && candidate.projectId===selection.projectId && candidate.instanceId===selection.instanceId && ports.rootState(selection.root)==="missing"
|
|
35
|
+
&& ports.liveness(candidate.pid,candidate.signature)==="live" && exactOrphanCommand(ports.command(candidate.pid),selection)
|
|
36
|
+
// Recheck the signature after argv inspection, immediately before the caller may signal.
|
|
37
|
+
&& ports.liveness(candidate.pid,candidate.signature)==="live";
|
|
38
|
+
}
|
|
39
|
+
/** Host-selected project only. Unsigned legacy pid files are never conductor signal authority. */
|
|
40
|
+
export function inspectSelectedOrphans(selection:OrphanSelection,registration:Project|undefined,control:ControlDescriptor|undefined,ports:OrphanPorts=orphanProcessPorts) {
|
|
41
|
+
ports.current();
|
|
42
|
+
const root=ports.rootState(selection.root);
|
|
43
|
+
if (root!=="missing") return {state:root==="present"?"not_orphan" as const:"unknown" as const,candidates:[] as SignedOrphan[]};
|
|
44
|
+
const proofs:{pid:number|null|undefined;signature:string|null|undefined}[]=[];
|
|
45
|
+
if (registration?.root===selection.root && registration.id===selection.projectId && registration.instanceId===selection.instanceId) proofs.push({pid:registration.pid,signature:registration.pidSignature});
|
|
46
|
+
if (control?.cwd===selection.root && control.projectId===selection.projectId && control.instanceId===selection.instanceId && control.pid===selection.pid) proofs.push({pid:control.pid,signature:control.pidSignature});
|
|
47
|
+
let unknown=proofs.length===0;
|
|
48
|
+
const candidates:SignedOrphan[]=[];
|
|
49
|
+
for (const proof of proofs) {
|
|
50
|
+
if (!Number.isSafeInteger(proof.pid) || proof.pid!<=0 || typeof proof.signature!=="string" || !proof.signature) {unknown=true;continue;}
|
|
51
|
+
const prior=candidates.find(c=>c.pid===proof.pid);
|
|
52
|
+
if (prior) {if(prior.signature!==proof.signature){unknown=true;candidates.splice(candidates.indexOf(prior),1);}continue;}
|
|
53
|
+
const candidate={pid:proof.pid!,signature:proof.signature,root:selection.root,projectId:selection.projectId,instanceId:selection.instanceId,self:proof.pid===selection.pid};
|
|
54
|
+
if (owns(candidate,selection,ports)) candidates.push(candidate); else if(ports.liveness(candidate.pid,candidate.signature)!=="gone") unknown=true;
|
|
55
|
+
}
|
|
56
|
+
return {state:unknown?"unknown" as const:"ready" as const,candidates};
|
|
57
|
+
}
|
|
58
|
+
/** A self candidate is returned for the daemon's existing reply-then-lifecycle-stop path, never self-signalled. */
|
|
59
|
+
export async function stopSelectedOrphan(candidate:SignedOrphan,selection:OrphanSelection,ports:OrphanPorts=orphanProcessPorts):Promise<"stopped"|"self"|"unknown"> {
|
|
60
|
+
if (!owns(candidate,selection,ports)) return "unknown";
|
|
61
|
+
if (candidate.pid===selection.pid) return "self";
|
|
62
|
+
for (const [signal,wait] of [["SIGTERM",3000],["SIGKILL",2000]] as const) {
|
|
63
|
+
if (!owns(candidate,selection,ports)) return ports.liveness(candidate.pid,candidate.signature)==="gone"?"stopped":"unknown";
|
|
64
|
+
try {ports.signal(candidate.pid,signal);} catch {return ports.liveness(candidate.pid,candidate.signature)==="gone"?"stopped":"unknown";}
|
|
65
|
+
const until=ports.now()+wait;
|
|
66
|
+
while (ports.now()<until) {ports.current();const alive=ports.liveness(candidate.pid,candidate.signature);if(alive==="gone")return "stopped";if(alive!=="live")return "unknown";await ports.sleep(100);}
|
|
67
|
+
}
|
|
68
|
+
return ports.liveness(candidate.pid,candidate.signature)==="gone"?"stopped":"unknown";
|
|
69
|
+
}
|
|
@@ -36,7 +36,7 @@ export function permissionGrant(peer: string, mode: PermissionMode, instances?:
|
|
|
36
36
|
const grant = (catalog?.definitionOf ?? definitionOf)(peer, instances)?.permission.grants?.[mode];
|
|
37
37
|
if (grant !== undefined) return grant;
|
|
38
38
|
if (mode === "ask") return "every write, edit and shell command asks";
|
|
39
|
-
return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write and
|
|
39
|
+
return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write, edit and proven bounded read-only shell commands inside the project run without asking; other shell commands and git writes still ask";
|
|
40
40
|
}
|
|
41
41
|
export const PI_EDIT_TOOLS = new Set(["edit", "write"]);
|
|
42
42
|
/** Native agent policy files are never included in scoped automatic file grants. */
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { accessSync, constants, lstatSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { guardPath, type ShellExecutionCheck } from "../local/tools.ts";
|
|
6
|
+
import { hostExecutable } from "./host-executable.ts";
|
|
7
|
+
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
8
|
+
import { realPath } from "./project.ts";
|
|
9
|
+
import { parseSimpleShell, readOnlyShellForm, type ReadOnlyProgram } from "./read-only-shell.ts";
|
|
10
|
+
import { hostShellEnvironment } from "../local/shell-environment.ts";
|
|
11
|
+
|
|
12
|
+
export interface ShellProofOptions { root:string; projectRoot?:string; deny:string[]; readOnly?:string[]; env?:Record<string,string> }
|
|
13
|
+
export interface ReadOnlyShellProof { fingerprint:string; program:ReadOnlyProgram; executable:string; argv:readonly string[] }
|
|
14
|
+
const within=(root:string,path:string)=>{const p=relative(root,path);return p==="" || p!==".."&&!p.startsWith("../")&&!isAbsolute(p);};
|
|
15
|
+
function firstProgram(name:string,root:string,path:string):string|undefined {
|
|
16
|
+
if(path.length>16384)return undefined;
|
|
17
|
+
const dirs=path.split(":");if(dirs.length>128)return undefined;
|
|
18
|
+
for(const dir of dirs) {
|
|
19
|
+
// Bash consumes the original PATH: do not normalize an ambiguous entry into a different program.
|
|
20
|
+
if(!isAbsolute(dir) || dir.split("/").includes(".."))return undefined;
|
|
21
|
+
const candidate=resolve(root,dir,name);
|
|
22
|
+
try {accessSync(candidate,constants.X_OK);if(lstatSync(candidate).isDirectory())continue;return candidate;}catch{/* Try only the next actual PATH entry. */}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function stamp(path:string,program:boolean):unknown {
|
|
27
|
+
try {const s=lstatSync(path);return program?[path,s.dev,s.ino,s.mode,s.size,s.mtimeMs,s.ctimeMs]:[path,s.dev,s.ino,s.mode];}catch(error){if((error as NodeJS.ErrnoException).code==="ENOENT")return [path,"missing"];throw error;}
|
|
28
|
+
}
|
|
29
|
+
function ancestors(path:string,stop:string):string[] {
|
|
30
|
+
const out=[path];for(let n=0;path!==stop && n<64;n++){const parent=dirname(path);if(parent===path)break;path=parent;out.push(path);}return out;
|
|
31
|
+
}
|
|
32
|
+
// Git's subsection names retain case. Expand the literal letters so the entire anchored key pattern is case-insensitive.
|
|
33
|
+
const GIT_HELPER_KEYS = /^(core\.fsmonitor|log\.showsignature|gpg\.(program|.+\.program)|diff\.(external|.+\.(command|textconv))|format\.pretty|pretty\..+)$/.source
|
|
34
|
+
.replace(/[a-z]/g, letter => `[${letter}${letter.toUpperCase()}]`);
|
|
35
|
+
/** No config values escape this predicate. Accept only the two exact helper overrides injected by the core. */
|
|
36
|
+
export function gitHelpersUnconfigured(git:string,cwd:string,env:NodeJS.ProcessEnv,current:()=>boolean):boolean {
|
|
37
|
+
if(!current() || Object.keys(env).some(key=>key==="GIT_CONFIG_COUNT"||key==="GIT_CONFIG_PARAMETERS"||key.startsWith("GIT_CONFIG_KEY_")||key.startsWith("GIT_CONFIG_VALUE_")))return false;
|
|
38
|
+
const argv=hubGitArgv(["-C",cwd,"config","--show-scope","--get-regexp",GIT_HELPER_KEYS]);
|
|
39
|
+
const result=spawnSync(git,argv.slice(1),{cwd,env:hubGitEnv(env),encoding:"utf8",timeout:1000,maxBuffer:16384,stdio:["ignore","pipe","pipe"]});
|
|
40
|
+
if(!current() || result.error || result.status!==0 || !result.stdout.endsWith("\n"))return false;
|
|
41
|
+
return result.stdout === "command\tcore.fsmonitor false\ncommand\tdiff.external \n";
|
|
42
|
+
}
|
|
43
|
+
/** A proof is private host metadata, never an argv/env rewrite or an authorization by itself. */
|
|
44
|
+
export function proveReadOnlyShell(input:unknown,options:ShellProofOptions,current:()=>boolean):ReadOnlyShellProof|undefined {
|
|
45
|
+
try {
|
|
46
|
+
if(!current() || !input || typeof input!=="object" || Array.isArray(input))return undefined;
|
|
47
|
+
const args=input as Record<string,unknown>;if(typeof args.command!=="string")return undefined;
|
|
48
|
+
if(args.timeout_s!==undefined && (!Number.isFinite(args.timeout_s)||typeof args.timeout_s!=="number"||args.timeout_s<1||args.timeout_s>600))return undefined;
|
|
49
|
+
const root=realPath(options.root),projectRoot=realPath(options.projectRoot??options.root),names:string[]=[root,projectRoot];
|
|
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
|
+
if(!form || !current())return undefined;
|
|
52
|
+
const env=hostShellEnvironment(options.env);
|
|
53
|
+
const lexical=firstProgram(form.program,root,env.PATH);if(!lexical || within(root,lexical) || within(projectRoot,lexical))return undefined;
|
|
54
|
+
const executable=hostExecutable(form.program,root,env.PATH);if(realPath(lexical)!==executable || within(projectRoot,executable) || !current())return undefined;
|
|
55
|
+
if(form.gitCwd && !gitHelpersUnconfigured(executable,form.gitCwd,env,current))return undefined;
|
|
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
|
+
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,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;
|
|
61
|
+
} catch {return undefined;}
|
|
62
|
+
}
|
|
63
|
+
interface Decision { command:string; current:()=>boolean; proof?:ReadOnlyShellProof }
|
|
64
|
+
export class ShellExecutionProofs {
|
|
65
|
+
private decisions=new WeakMap<object,Decision>();
|
|
66
|
+
constructor(private readonly options:ShellProofOptions){}
|
|
67
|
+
private key(input:unknown):input is Record<string,unknown>{return !!input&&typeof input==="object"&&!Array.isArray(input)&&typeof (input as Record<string,unknown>).command==="string";}
|
|
68
|
+
ordinary(input:unknown,current:()=>boolean):void {if(this.key(input))this.decisions.set(input,{command:input.command as string,current});}
|
|
69
|
+
readonly(input:unknown,proof:ReadOnlyShellProof,current:()=>boolean):void {if(this.key(input))this.decisions.set(input,{command:input.command as string,current,proof});}
|
|
70
|
+
check(input:unknown):ShellExecutionCheck {
|
|
71
|
+
if(!this.key(input))return "ask";const decision=this.decisions.get(input);
|
|
72
|
+
if(!decision)return "ask";
|
|
73
|
+
if(!decision.current())return "aborted";
|
|
74
|
+
if(input.command!==decision.command)return "ask";
|
|
75
|
+
if(!decision.proof)return "valid";
|
|
76
|
+
const proof=proveReadOnlyShell(input,this.options,decision.current);
|
|
77
|
+
if(!decision.current())return "aborted";
|
|
78
|
+
if(!proof || proof.fingerprint!==decision.proof.fingerprint){this.decisions.delete(input);return "ask";}
|
|
79
|
+
return "valid";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** Lexical data only. A successful parse grants no permission and proves no program read-only. */
|
|
2
|
+
export interface SimpleShellWords {
|
|
3
|
+
words: readonly string[];
|
|
4
|
+
/** Syntax-looking characters survived as quoted/escaped data; the grant policy must decide whether to accept them. */
|
|
5
|
+
quotedMeta: boolean;
|
|
6
|
+
}
|
|
7
|
+
export const READ_ONLY_SHELL_LIMITS = { characters: 4000, words: 128 } as const;
|
|
8
|
+
const ACTIVE = ";|&<>`$(){}*?[]~#";
|
|
9
|
+
|
|
10
|
+
/** One bounded literal command, without active operators or expansion. Never executes or resolves any word. */
|
|
11
|
+
export function parseSimpleShell(command: unknown): SimpleShellWords | undefined {
|
|
12
|
+
if (typeof command !== "string" || command.length > READ_ONLY_SHELL_LIMITS.characters
|
|
13
|
+
|| command.includes("\n") || command.includes("\r") || command.includes("\0")) return undefined;
|
|
14
|
+
const words: string[] = [];
|
|
15
|
+
let word = "", started = false, quote: "" | "'" | '"' = "", quotedMeta = false;
|
|
16
|
+
const append = (character: string, literal = false) => {
|
|
17
|
+
word += character;
|
|
18
|
+
started = true;
|
|
19
|
+
if (literal && (ACTIVE.includes(character) || character === "\\")) quotedMeta = true;
|
|
20
|
+
};
|
|
21
|
+
const finish = () => {
|
|
22
|
+
if (!started) return true;
|
|
23
|
+
words.push(word);
|
|
24
|
+
word = ""; started = false;
|
|
25
|
+
return words.length <= READ_ONLY_SHELL_LIMITS.words;
|
|
26
|
+
};
|
|
27
|
+
for (let i = 0; i < command.length; i++) {
|
|
28
|
+
const character = command[i]!;
|
|
29
|
+
if (quote === "'") {
|
|
30
|
+
if (character === "'") quote = "";
|
|
31
|
+
else append(character, true);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (quote === '"') {
|
|
35
|
+
if (character === '"') { quote = ""; continue; }
|
|
36
|
+
if (character === "$" || character === "`") return undefined;
|
|
37
|
+
if (character === "\\") return undefined;
|
|
38
|
+
append(character, true);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (character === " " || character === "\t") { if (!finish()) return undefined; continue; }
|
|
42
|
+
if (character === "'" || character === '"') { quote = character; started = true; continue; }
|
|
43
|
+
if (character === "\\") {
|
|
44
|
+
const next = command[++i];
|
|
45
|
+
if (next === undefined) return undefined;
|
|
46
|
+
append(next, true);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (ACTIVE.includes(character) && !(character === "~" && started)) return undefined;
|
|
50
|
+
append(character);
|
|
51
|
+
}
|
|
52
|
+
if (quote || !finish() || !words.length) return undefined;
|
|
53
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*[+]?=/.test(words[0]!)) return undefined;
|
|
54
|
+
return { words, quotedMeta };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const READ_ONLY_PROGRAMS = ["ls","cat","head","tail","wc","rg","grep","stat","file","tree","pwd","find","git"] as const;
|
|
58
|
+
export type ReadOnlyProgram = typeof READ_ONLY_PROGRAMS[number];
|
|
59
|
+
export interface ReadOnlyShellForm { program:ReadOnlyProgram; paths:readonly string[]; gitCwd?:string }
|
|
60
|
+
type FlagKind = "none"|"number"|"pattern"|"path";
|
|
61
|
+
const flags=(none:string[],values:Record<string,FlagKind>={}):Record<string,FlagKind>=>Object.assign(Object.fromEntries(none.map(flag=>[flag,"none"])),values);
|
|
62
|
+
const numberArg=(value:string)=>/^[0-9]{1,7}$/.test(value) && Number(value)<=1_000_000;
|
|
63
|
+
function options(args:string[],rules:Record<string,FlagKind>,path:(value:string)=>void) {
|
|
64
|
+
const positional:string[]=[],seen=new Set<string>();let ended=false;
|
|
65
|
+
for(let i=0;i<args.length;i++) {
|
|
66
|
+
const word=args[i]!;
|
|
67
|
+
if(!ended && word==="--"){ended=true;continue;}
|
|
68
|
+
if(ended || !word.startsWith("-") || word==="-"){positional.push(word);continue;}
|
|
69
|
+
let key=word,value:string|undefined;
|
|
70
|
+
if(word.startsWith("--") && word.includes("=")){const at=word.indexOf("=");key=word.slice(0,at);value=word.slice(at+1);}
|
|
71
|
+
if(!Object.hasOwn(rules,key) && /^-[^-].+/.test(word)) {
|
|
72
|
+
const letters=word.slice(1);let consumed=false;
|
|
73
|
+
for(let n=0;n<letters.length;n++) {
|
|
74
|
+
const short=`-${letters[n]}`;
|
|
75
|
+
if(!Object.hasOwn(rules,short))return undefined;
|
|
76
|
+
seen.add(short);
|
|
77
|
+
if(rules[short]!=="none") {key=short;value=letters.slice(n+1)||args[++i];consumed=true;break;}
|
|
78
|
+
}
|
|
79
|
+
if(!consumed)continue;
|
|
80
|
+
}
|
|
81
|
+
if(!Object.hasOwn(rules,key))return undefined;
|
|
82
|
+
seen.add(key);const kind=rules[key]!;
|
|
83
|
+
if(kind==="none"){if(value!==undefined)return undefined;continue;}
|
|
84
|
+
value??=args[++i];if(value===undefined || value.startsWith("-") || kind==="number"&&!numberArg(value))return undefined;
|
|
85
|
+
if(kind==="path")path(value);
|
|
86
|
+
}
|
|
87
|
+
return {positional,seen};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Structural allowlist only; the host must still prove executable identity, paths, config and current authority. */
|
|
91
|
+
export function readOnlyShellForm(command:unknown,root:string,readPath:(path:string,base?:string)=>string):ReadOnlyShellForm|undefined {
|
|
92
|
+
try {
|
|
93
|
+
const parsed=parseSimpleShell(command);if(!parsed)return undefined;
|
|
94
|
+
const [name,...args]=parsed.words;
|
|
95
|
+
if(!READ_ONLY_PROGRAMS.includes(name as ReadOnlyProgram))return undefined;
|
|
96
|
+
const program=name as ReadOnlyProgram,paths:string[]=[];
|
|
97
|
+
const path=(value:string,base=root)=>{if(value!=="-")paths.push(readPath(value,base));};
|
|
98
|
+
path(".");
|
|
99
|
+
if(program==="git") {
|
|
100
|
+
let cwd=root,i=0;
|
|
101
|
+
while(i<args.length){if(args[i]==="--no-pager"||args[i]==="--no-optional-locks"){i++;continue;}if(args[i]==="-C"&&args[i+1]!==undefined){cwd=readPath(args[i+1]!,cwd);paths.push(cwd);i+=2;continue;}break;}
|
|
102
|
+
const sub=args[i++],rest=args.slice(i);
|
|
103
|
+
const ref=(value:string)=>{
|
|
104
|
+
if(!value || value.startsWith("-") || value.startsWith("/") || value.startsWith("~") || value.includes("..")&&value.startsWith("."))throw new Error("unknown revision");
|
|
105
|
+
if(value.startsWith(":") || /[*?\[\]]/.test(value))throw new Error("unresolved pathspec");
|
|
106
|
+
const colon=value.indexOf(":");if(colon>=0)path(value.slice(colon+1),cwd);else path(value,cwd);
|
|
107
|
+
};
|
|
108
|
+
if(sub==="branch") {if(rest[0]!=="--list" || rest.slice(1).some(a=>a.startsWith("-")))return undefined;return {program,paths,gitCwd:cwd};}
|
|
109
|
+
if(sub==="cat-file") {if(rest.length!==2||!["-p","-t","-s"].includes(rest[0]!))return undefined;ref(rest[1]!);return {program,paths,gitCwd:cwd};}
|
|
110
|
+
// These commands do not accept both required diff-disabling flags, so their shell forms keep asking.
|
|
111
|
+
if(sub==="blame"||sub==="grep")return undefined;
|
|
112
|
+
let rule:Record<string,FlagKind>;
|
|
113
|
+
if(sub==="status")rule=flags(["-s","-b","--short","--branch","--porcelain","--ignored"],{"--untracked-files":"pattern"});
|
|
114
|
+
else if(sub==="rev-parse")rule=flags(["--show-toplevel","--git-dir","--is-inside-work-tree","--show-prefix","--verify","--short"]);
|
|
115
|
+
else if(sub==="ls-files")rule=flags(["-z","--cached","--deleted","--modified","--others","--exclude-standard","--stage","--error-unmatch"]);
|
|
116
|
+
else if(sub==="describe")rule=flags(["--always","--tags","--long","--all","--dirty"],{"--abbrev":"number","--match":"pattern","--exclude":"pattern"});
|
|
117
|
+
else if(sub==="log"||sub==="diff"||sub==="show") {
|
|
118
|
+
rule=flags(["--no-ext-diff","--no-textconv","--no-patch","--oneline","--all","--decorate","--no-decorate","--name-only","--name-status","--stat","-p","--patch","--cached","--staged"],{"-n":"number","--max-count":"number"});
|
|
119
|
+
} else return undefined;
|
|
120
|
+
const parsedArgs=options(rest,rule,value=>path(value,cwd));if(!parsedArgs)return undefined;
|
|
121
|
+
const patch=sub==="diff" || sub==="show" || sub==="log" && ["-p","--patch","--stat"].some(flag=>parsedArgs.seen.has(flag));
|
|
122
|
+
if(patch && !(parsedArgs.seen.has("--no-ext-diff")&&parsedArgs.seen.has("--no-textconv")))return undefined;
|
|
123
|
+
for(const value of parsedArgs.positional)ref(value);
|
|
124
|
+
return {program,paths,gitCwd:cwd};
|
|
125
|
+
}
|
|
126
|
+
if(program==="find") {
|
|
127
|
+
let i=0,depth=0;while(i<args.length && !args[i]!.startsWith("-") && !["(",")","!"].includes(args[i]!))path(args[i++]!);
|
|
128
|
+
for(;i<args.length;i++) {
|
|
129
|
+
const value=args[i]!;
|
|
130
|
+
if(value==="("){if(++depth>16)return undefined;continue;}if(value===")"){if(--depth<0)return undefined;continue;}
|
|
131
|
+
if(["!","-a","-o","-not","-print","-print0","-ls","-depth"].includes(value))continue;
|
|
132
|
+
if(["-name","-iname","-path","-ipath","-regex","-iregex"].includes(value)){if(args[++i]===undefined)return undefined;continue;}
|
|
133
|
+
if(value==="-type"){if(!["f","d","l","b","c","p","s"].includes(args[++i]??""))return undefined;continue;}
|
|
134
|
+
if(["-maxdepth","-mindepth"].includes(value)){if(!numberArg(args[++i]??""))return undefined;continue;}
|
|
135
|
+
if(["-newer","-samefile"].includes(value)){if(args[i+1]===undefined)return undefined;path(args[++i]!);continue;}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
return depth===0?{program,paths}:undefined;
|
|
139
|
+
}
|
|
140
|
+
let rule:Record<string,FlagKind>;
|
|
141
|
+
if(program==="ls")rule=flags(["-a","-A","-l","-h","-1","-d","-F","-i","-p","-r","-R","-s","-S","-t","-u"]);
|
|
142
|
+
else if(program==="cat")rule=flags(["-b","-e","-n","-s","-t","-u","-v"]);
|
|
143
|
+
else if(program==="head"||program==="tail")rule=flags(["-q","-v"],{"-n":"number","-c":"number","--lines":"number","--bytes":"number"});
|
|
144
|
+
else if(program==="wc")rule=flags(["-c","-l","-m","-w","-L"]);
|
|
145
|
+
else if(program==="stat")rule=flags(["-L"]);
|
|
146
|
+
else if(program==="file")rule=flags(["-b","-i","-L","-h"]);
|
|
147
|
+
else if(program==="pwd")rule=flags(["-L","-P"]);
|
|
148
|
+
else if(program==="tree")rule=flags(["-a","-d","-f","-i","-p","-s","-h","-D","-F","-q","-N","-n","--noreport","--dirsfirst"],{"-L":"number","-I":"pattern","-P":"pattern"});
|
|
149
|
+
else if(program==="rg")rule=flags(["-n","-l","-i","-w","-s","-S","-F","-v","-H","-h","-o","-0","-q","-c","-u","-U","-a","--files","--hidden","--no-ignore","--no-heading","--line-number","--files-with-matches","--count","--ignore-case","--fixed-strings"],{"-e":"pattern","--regexp":"pattern","-f":"path","--file":"path","-g":"pattern","--glob":"pattern","--iglob":"pattern","-t":"pattern","--type":"pattern","-T":"pattern","--type-not":"pattern","-m":"number","--max-count":"number","--max-depth":"number","-A":"number","-B":"number","-C":"number"});
|
|
150
|
+
else if(program==="grep")rule=flags(["-n","-l","-i","-w","-x","-F","-E","-v","-H","-h","-o","-c","-q","-s","-r"],{"-e":"pattern","-f":"path","-A":"number","-B":"number","-C":"number","-m":"number","--include":"pattern","--exclude":"pattern","--exclude-dir":"pattern"});
|
|
151
|
+
else return undefined;
|
|
152
|
+
const parsedArgs=options(args,rule,path);if(!parsedArgs)return undefined;
|
|
153
|
+
const operands=[...parsedArgs.positional];
|
|
154
|
+
if(program==="pwd" && operands.length)return undefined;
|
|
155
|
+
if(program==="rg"||program==="grep") {
|
|
156
|
+
const pattern=parsedArgs.seen.has("-e")||parsedArgs.seen.has("--regexp")||parsedArgs.seen.has("-f")||parsedArgs.seen.has("--file")||parsedArgs.seen.has("--files");
|
|
157
|
+
if(!pattern && operands.shift()===undefined)return undefined;
|
|
158
|
+
}
|
|
159
|
+
for(const value of operands)path(value);
|
|
160
|
+
return {program,paths};
|
|
161
|
+
} catch {return undefined;}
|
|
162
|
+
}
|
|
@@ -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.`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export interface ReviewerPoolConfig { pool: string[]; rerotate: boolean }
|
|
1
|
+
export interface ReviewerPoolConfig { pool: string[]; rerotate: boolean; sameFamily?: "never" }
|
|
2
2
|
|
|
3
3
|
/** Kind order is project policy; capability and isolation evidence remain host-owned registry facts. */
|
|
4
4
|
export function reviewerPoolConfig(raw: unknown, defaults: readonly string[], known: readonly string[]): ReviewerPoolConfig {
|
|
@@ -7,5 +7,6 @@ export function reviewerPoolConfig(raw: unknown, defaults: readonly string[], kn
|
|
|
7
7
|
const pool = value.pool === undefined ? defaults : value.pool;
|
|
8
8
|
if (!Array.isArray(pool) || pool.length < 1 || pool.length > 16 || pool.some(kind => typeof kind !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(kind) || !known.includes(kind)) || new Set(pool).size !== pool.length) throw new Error("review.pool must list 1..16 distinct known kinds");
|
|
9
9
|
if (value.rerotate !== undefined && typeof value.rerotate !== "boolean") throw new Error("review.rerotate must be a boolean");
|
|
10
|
-
|
|
10
|
+
if (value.same_family !== undefined && value.same_family !== "fallback" && value.same_family !== "never") throw new Error("review.same_family must be \"fallback\" or \"never\"");
|
|
11
|
+
return { pool: [...pool], rerotate: value.rerotate === true, ...(value.same_family === "never" ? { sameFamily: "never" as const } : {}) };
|
|
11
12
|
}
|
package/src/hub/reviewer-pool.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface ReviewerPoolInput {
|
|
|
8
8
|
continuityFamily?: string;
|
|
9
9
|
rerotate?: boolean;
|
|
10
10
|
failed?: readonly string[];
|
|
11
|
+
/** `never` (#820): an implementer-family candidate is never selected, not even as the last resort. */
|
|
12
|
+
sameFamily?: "fallback" | "never";
|
|
11
13
|
}
|
|
12
14
|
export interface ReviewerPoolSelection {
|
|
13
15
|
reviewer: string | null;
|
|
@@ -65,6 +67,7 @@ export function selectReviewerPool(input: ReviewerPoolInput): ReviewerPoolSelect
|
|
|
65
67
|
return finish({ reviewer: candidate.id, family: candidate.family, reason });
|
|
66
68
|
}
|
|
67
69
|
// The caller supplies separate fresh reviewer instances; this does not authorize an implementer's own session.
|
|
70
|
+
if (input.sameFamily === "never") return finish({ reviewer: null, blocked: "no reviewer can run" });
|
|
68
71
|
for (const candidate of ordered) {
|
|
69
72
|
if (!candidate.id || input.implementerFamily === undefined || candidate.family !== input.implementerFamily) continue;
|
|
70
73
|
const blocked = unavailable(candidate);
|