@signalridge/pi-approval-core 0.0.0-stage → 1.2.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 +5 -0
- package/LICENSE +21 -0
- package/README.md +26 -2
- package/coreAPI.md +72 -0
- package/package.json +29 -4
- package/src/adapters.ts +115 -0
- package/src/bash-supervisor.mjs +59 -0
- package/src/contract.ts +276 -0
- package/src/files.ts +170 -0
- package/src/index.ts +3 -0
- package/src/local-bash.ts +122 -0
- package/src/local-runtime.ts +21 -0
- package/src/tools.ts +199 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 signalridge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @signalridge/pi-approval-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Independent, side-effect-free library for local human-approval service discovery and reviewed native Pi tool definitions. Tested against Pi **1.0.4**; host modules are peers. Importing the library installs no extension, settings, listeners, tools, timers, or resources. Calling its factories/discovery functions is explicit.
|
|
4
|
+
|
|
5
|
+
See [coreAPI.md](coreAPI.md) for the version-1 contract. This is intentionally separate from managed-subagent protocols. The service/request/factory/lifecycle shape remains API v1. Tool-call IDs are opaque bounded Unicode (including provider `|` and native nested `/n` identities), separate from strict source/service/request labels.
|
|
6
|
+
|
|
7
|
+
`APPROVAL_ADAPTER_QUERY_CHANNEL`, `validateAdapterQuery`, `validateAdapter`, and `queryApprovalAdapter` support explicit cooperative command gates. Exactly one synchronous reply must identify the requested tool and its current effective schema **by object identity**; absent/duplicate/malformed/copied-schema replies deny. Cooperating extensions own their listeners and attest only for live owned gates. Importing this library never registers events. See the contract for the exact query/response shapes.
|
|
8
|
+
|
|
9
|
+
These factories construct known local backends: public native SDK edit/write operations and an owned POSIX BashOperations backend. An SDK host must explicitly choose this integration; never use public `builtin:<name>` metadata as proof of an arbitrary overridden backend. The approvals extension's separate human `--approvals-native-tools` flag is not required by hosts constructing their own known factories.
|
|
10
|
+
|
|
11
|
+
## Reviewed native tools
|
|
12
|
+
|
|
13
|
+
`createApprovalToolDefinitions({ source, request, isCurrent, isToolCurrent?, shellPath?, commandPrefix? })` returns `[edit, write, bash]` definitions for `registerTool` or SDK `customTools`. The host supplies a human-owned request callback and a current-owner predicate retaining the same `captureApprovalService(...).assertCurrent` guard through post-approval IO. Optional `isToolCurrent(ctx, name)` rechecks exact-name binding/admission at each ownership boundary; omitted means no additional admission restriction for compatible hosts. Each invocation revalidates the final parameters with the SDK's schemas, then deep-clones/freezes them **before its first await**. Calls share no approval operation or context.
|
|
14
|
+
|
|
15
|
+
- Edit uses the public SDK edit factory. Its supplied `readFile` captures exact original bytes; its `writeFile` reviews the actual SDK-generated output, including BOM, line endings, fuzzy matching, and original-file batch semantics. No edit algorithm is reimplemented. The transcript call renderer is a constant, width-clipped ASCII label: it never reads a file or generates a synchronous diff. Complete diffs appear only in human review.
|
|
16
|
+
- Write supplies a no-op `mkdir`, reads a bounded existing/missing target, reviews the actual output, rechecks identity and bytes, then performs the real directory creation and write.
|
|
17
|
+
- Both await approval **inside the SDK factory's existing mutation queue**. Never wrap these definitions in another `withFileMutationQueue`: that queue is non-reentrant and nested acquisition deadlocks. Different files can wait independently. Cancelled calls already queued wait for their predecessor, then fail without requesting approval and release their slot.
|
|
18
|
+
- Bash reviews the exact final command/CWD received at `exec`, retaining the public SDK tool factory, output accumulation and truncation, **not** delegating to `createLocalBashOperations`. A named owned backend uses public `getShellConfig`/`getAgentDir` and Node APIs; after asynchronous read-only CWD access it checks ownership immediately before supervisor spawn and again immediately before command IPC dispatch. Supplied signal, timeout, environment and output callback identities/values are captured before review; default environments preserve the agent `bin` PATH convention. Configured shell arguments and argv/stdin command transport are preserved. The command input hash combines final command/CWD/timeout with the frozen input hash. `createApprovalBashOperations` also supports public `user_bash` interception.
|
|
19
|
+
- The short-lived POSIX supervisor anchors the process group until direct-shell exit, timeout or cancellation, then reclaims remaining group members. IPC disconnect reclaims the group even if the host exits or is killed, without host signal handlers or reusable-PID tracking. Output callback failures reject only after cleanup. Host inherited output pipes drain for at most 250 ms after supervisor exit. Factories/imports start no processes/listeners/timers. This backend deliberately does **not** allow background group members to outlive the foreground command. Reviewed bash requires POSIX and standalone Node.js with the public `process.getBuiltinModule("node:sea").isSea()` probe returning false. Windows, Bun (including Bun-compiled Pi), Node SEA applications and runtimes without that probe are refused **before approval or any subprocess**; no interpreter probe launches the current executable. Other embedding runtimes are not qualified. Processes deliberately escaping the group/session are outside this trusted-process integration, not sandboxed or promised reclaimed. Liveness is checked until irreversible command IPC dispatch; retirement after dispatch cannot roll back a command's effects.
|
|
20
|
+
|
|
21
|
+
## Bounds and limitations
|
|
22
|
+
|
|
23
|
+
Files and final inputs are capped at 256 KiB; human review payloads at 64 KiB. Before entering the synchronous line diff, file reviews also require at most **2,048 combined line tokens** across before and after: each text uses `text.split("\n").length`, including the empty text token for a missing file and a trailing empty token after a terminal newline. This conservative work budget rejects even a small edit to a file exceeding it; split the operation into separately reviewed smaller files instead. The byte limits are unchanged. Oversized reviews fail, never approve clipped changes. Invalid UTF-8 and NUL-containing files are rejected. Display controls (including BOM, CR, tabs, bidi/format characters, and ANSI escapes) are shown explicitly as `\\u{hex}`; literal backslashes are doubled. Hashes cover complete actual bytes, not the escaped display.
|
|
24
|
+
|
|
25
|
+
Target symlinks, nonregular files, and multiply-linked files are denied. Existing ancestor realpaths and lstat identities are captured/rechecked; stable ancestor symlinks are allowed. File identities and bytes are rechecked after approval and, for write, after mkdir. This is **not OS-atomic** against external writers, aliases, other processes, or separately loaded queue implementations. A write failure or cancellation after approved mkdir can leave directories behind; there is no atomic rollback claim.
|
|
26
|
+
|
|
27
|
+
Approval is **not a sandbox**. Trusted extension callbacks and event-bus peers run in the same process and may access the filesystem or execute arbitrary code. Other execution tools, MCP tools, and external processes require separate restrictions. Human review can expose credentials contained in a proposed change; the library does not log them or send review payloads in lifecycle events.
|
package/coreAPI.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Local approval API v1
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
const captured = captureApprovalService(rootPi.events); // capture before first await
|
|
5
|
+
// undefined means no approval extension is loaded. The host decides whether ungated
|
|
6
|
+
// operation is admissible; do not convert a present-but-failing peer into absence.
|
|
7
|
+
const definitions = createApprovalToolDefinitions({
|
|
8
|
+
source: "child:owned-run-id",
|
|
9
|
+
request: (operation, options) => {
|
|
10
|
+
if (!captured) throw new Error("No human approval service");
|
|
11
|
+
return captured.service.request(operation, options);
|
|
12
|
+
},
|
|
13
|
+
isCurrent: (invocationContext) => {
|
|
14
|
+
if (!captured) return false;
|
|
15
|
+
captured.assertCurrent(); // SAME capture, including after post-approval IO
|
|
16
|
+
return callerStillOwns(invocationContext);
|
|
17
|
+
},
|
|
18
|
+
// Optional: isToolCurrent: (ctx, name) => exactBindingAndAdmissionStillCurrent(ctx, name),
|
|
19
|
+
});
|
|
20
|
+
// SDK: customTools: [...definitions]; do not copy/forward all child UI methods.
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Reviewed bash additionally requires a POSIX standalone Node.js host with public SEA detection. Bun/compiled Pi, Node SEA and missing/unknown probe capabilities reject before the approval callback or any child process. Imports and factory creation remain resource-free, and edit/write do not invoke this bash-only runtime check. The implementation never probes interpreter compatibility by launching `process.execPath`.
|
|
24
|
+
|
|
25
|
+
`ApprovalService = { readonly version: 1; readonly id: string; request(operation, options): Promise<void> }`.
|
|
26
|
+
|
|
27
|
+
`ApprovalRequestOptions = { readonly signal?: AbortSignal; readonly isCurrent: () => boolean }`. This is the caller's **current** ownership predicate, not a saved boolean. False, cancellation, and callback exceptions deny. A captured service never reconnects to another peer after retirement.
|
|
28
|
+
|
|
29
|
+
`ApprovalOperation` is readonly validated bounded data:
|
|
30
|
+
|
|
31
|
+
- Common: `source`, `toolName`, `cwd`, `toolCallId`, SHA-256 `inputHash`, full bounded human `preview`.
|
|
32
|
+
- `kind: "command"`: `command`; `toolName` is a strict identifier (up to 80 bytes), e.g. `bash` or an explicitly integrated background command tool. The adapter must supply the actual executed command/CWD, not an earlier hook proposal, and generate a truthful escaped preview of all relevant normalized inputs.
|
|
33
|
+
- `kind: "write" | "edit"`: canonical matching `toolName`, absolute `filePath`, `before: ApprovalSnapshot | null`, `after: ApprovalSnapshot`. Edit cannot have a missing original. Snapshots contain exact `text`, `bytes`, and SHA-256 `sha256`; all are checked and frozen. Canonical definitions construct these from actual SDK IO.
|
|
34
|
+
|
|
35
|
+
Kinds are finite; there is no model-callable response/approve tool. Service functions are human-owned trusted peer closures, **not serialized data**. This event bus is not a serialization or security boundary. Source/service/request labels use strict ASCII identifiers of at most 160 UTF-8 bytes. Provider-owned tool-call identifiers instead use `approvalToolCallId`: nonempty valid Unicode, at most 1024 UTF-8 bytes, preserved exactly without truncation or normalization. This accepts OpenAI Responses `call_…|fc_…` and native nested `/n` suffixes. Lifecycle validation uses the same opaque identity parser. Text is valid Unicode and bounded by UTF-8 byte length.
|
|
36
|
+
|
|
37
|
+
`APPROVAL_QUERY_CHANNEL` is `signalridge:approvals:query:v1`. Query payload: frozen `{ version: 1, respond(service: unknown): void }`. Trusted peers must respond **synchronously**, with exactly `{ version: 1, id, request }`. No peer => undefined. Any malformed peer or more than one response => error, including duplicate responses from the same peer. Captures check object/function/id identity before and after requests and through the ownership callback while pending. Extensions own subscription registration and cleanup.
|
|
38
|
+
|
|
39
|
+
`captureApprovalService(events)` returns `undefined` on absence, otherwise frozen `{ service, assertCurrent(): void }`. `service` has exactly the existing v1 shape. The synchronous guard checks the same original peer object, ID and request function with the same sticky retirement as requests, without requesting another human decision. An adapter with asynchronous startup after approval must carry this invocation-local guard to its irreversible dispatch boundary; missing, replaced, duplicate or malformed peers deny without reconnecting.
|
|
40
|
+
|
|
41
|
+
`APPROVAL_LIFECYCLE_CHANNEL` is `signalridge:approvals:lifecycle:v1`. Payload:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
{ version: 1, serviceId, requestId, source, toolCallId,
|
|
45
|
+
phase: "waiting" | "respond" | "resolved",
|
|
46
|
+
outcome?: "approved" | "denied" | "cancelled" | "retired" | "expired" | "error" }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Waiting has no outcome; respond/resolved require one. Lifecycle events contain bounded metadata only, never commands, diffs, credentials, or snapshots. `validateLifecycle` checks all fields. A child adapter may pause its owned default tool timeout on matching `source`/`toolCallId` waiting and resume on resolved. Events are observational, not authorization responses; approval time must not be conflated with native command timeout. Alternatively bracket the adapter's awaited request callback, which also covers discovery errors.
|
|
50
|
+
|
|
51
|
+
## Cooperative command-tool capability
|
|
52
|
+
|
|
53
|
+
`APPROVAL_ADAPTER_QUERY_CHANNEL` is `signalridge:approvals:adapter:v1`.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// Query (validated by validateAdapterQuery):
|
|
57
|
+
{ version: 1, toolName: string, respond(value: unknown): void }
|
|
58
|
+
// Response (validated by validateAdapter):
|
|
59
|
+
{ version: 1, toolName: string, parameters: TSchema }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`queryApprovalAdapter(events, toolName, effectiveToolInfo.parameters)` requires exactly one **synchronous** valid response with the requested tool name and the **same schema object** as the current effective `ToolInfo.parameters`. None, duplicate, malformed, wrong-name, copied-schema, and asynchronous replies deny. Validators reject extra fields, prototypes, accessors, and unsupported versions. Tool names are strict ASCII tool identifiers, at most 256 UTF-8 bytes.
|
|
63
|
+
|
|
64
|
+
A cooperating extension registers its own listener **after** registering its command tools. It responds only for schemas it owns while its context and effective binding remain live. Advertising this capability attests that the tool gates its final native command/CWD through the human service; it is not an authorization grant. The library registers no listeners. Source labels or names alone never establish capability. This is a trusted-extension integration boundary, not an OS sandbox. Older adapters without this protocol are blocked by the approvals extension for command-shaped calls.
|
|
65
|
+
|
|
66
|
+
`requiresApprovalAdapter(input)` recognizes an own `command`, or `path` plus `content`/`edits`/known old/new-text properties, at the root or one `input` envelope. `schemaRequiresApprovalAdapter(parameters)` also recognizes those known own properties in the schema's root `properties` or one `input.properties`, even when optional. Neither evaluates arbitrary semantics or expands schema references/compositions. This conservative pre-invocation classification prevents known optional normalizer inputs bypassing a root policy without a public post-handler boundary. Managed hosts should additionally recheck actual mutable native arguments after downstream handlers using the originally captured execution name and effective schema.
|
|
67
|
+
|
|
68
|
+
File factory review generation requires at most 2,048 combined `beforeText.split("\n").length + afterText.split("\n").length` tokens before synchronous diffing; missing before text is empty. Files/inputs remain 256 KiB and completed reviews 64 KiB. No partial review is approved.
|
|
69
|
+
|
|
70
|
+
The service/request/factory/lifecycle API remains v1, with no added required fields. Root native-backend certification is a separate human CLI policy, not a model-callable permission and not required by factories constructed by a host that knows their backend.
|
|
71
|
+
|
|
72
|
+
The extension requests no automatic UI: humans use `/approvals` while a tool is paused. Child SDK sessions have no UI by default. Hosts integrating the captured root service should exclude a separately inherited approval extension to avoid shadowing/headless double gates. Preserve stronger admission/Plan restrictions before injecting customTools. No managed-subagent protocol changes or child/background adapter implementations are part of this library.
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-approval-core",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Side-effect-free local approval contracts and reviewed native Pi tool factories.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"signalridgePackage": { "kind": "library" },
|
|
9
|
+
"exports": { ".": { "types": "./src/index.ts", "default": "./src/index.ts" } },
|
|
10
|
+
"files": ["README.md", "LICENSE", "CHANGELOG.md", "coreAPI.md", "src"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"lint": "biome check src/ test/ vitest.config.ts --error-on-warnings",
|
|
13
|
+
"format": "biome check --write src/ test/ vitest.config.ts",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "vitest run --config vitest.config.ts",
|
|
16
|
+
"check": "bun run lint && bun run typecheck && bun run test"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": { "@earendil-works/pi-coding-agent": "*", "typebox": "*" },
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@earendil-works/pi-coding-agent": "1.0.4",
|
|
21
|
+
"typebox": "1.3.27",
|
|
22
|
+
"@biomejs/biome": "^2.5.7",
|
|
23
|
+
"@types/node": "^24.10.0",
|
|
24
|
+
"typescript": "^6.0.0",
|
|
25
|
+
"vitest": "^4.1.11"
|
|
26
|
+
},
|
|
27
|
+
"repository": { "type": "git", "url": "https://github.com/signalridge/pi-extensions", "directory": "packages/pi-approval-core" },
|
|
28
|
+
"homepage": "https://github.com/signalridge/pi-extensions/tree/main/packages/pi-approval-core",
|
|
29
|
+
"bugs": { "url": "https://github.com/signalridge/pi-extensions/issues" },
|
|
30
|
+
"publishConfig": { "access": "public" }
|
|
31
|
+
}
|
package/src/adapters.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { IsSchema, type TSchema } from "typebox";
|
|
2
|
+
import { type ApprovalEvents, boundedText } from "./contract.js";
|
|
3
|
+
|
|
4
|
+
export const APPROVAL_ADAPTER_QUERY_CHANNEL = "signalridge:approvals:adapter:v1";
|
|
5
|
+
export interface ApprovalAdapterQuery {
|
|
6
|
+
readonly version: 1;
|
|
7
|
+
readonly toolName: string;
|
|
8
|
+
readonly respond: (adapter: unknown) => void;
|
|
9
|
+
}
|
|
10
|
+
export interface ApprovalAdapter {
|
|
11
|
+
readonly version: 1;
|
|
12
|
+
readonly toolName: string;
|
|
13
|
+
/** Exact effective public ToolInfo.parameters object, not a copy. */
|
|
14
|
+
readonly parameters: TSchema;
|
|
15
|
+
}
|
|
16
|
+
function fields(value: unknown, names: string[]): Record<string, unknown> {
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid approval adapter");
|
|
18
|
+
const prototype = Object.getPrototypeOf(value);
|
|
19
|
+
if (prototype !== null && prototype !== Object.prototype) throw new Error("Invalid approval adapter prototype");
|
|
20
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
21
|
+
if (
|
|
22
|
+
Reflect.ownKeys(descriptors).some((key) => typeof key !== "string" || !names.includes(key)) ||
|
|
23
|
+
names.some((key) => !descriptors[key] || !("value" in descriptors[key]))
|
|
24
|
+
)
|
|
25
|
+
throw new Error("Invalid approval adapter fields");
|
|
26
|
+
return value as Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
function toolName(value: unknown): string {
|
|
29
|
+
const name = boundedText(value, 256);
|
|
30
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_.:-]*$/u.test(name)) throw new Error("Invalid approval adapter tool name");
|
|
31
|
+
return name;
|
|
32
|
+
}
|
|
33
|
+
export function validateAdapterQuery(value: unknown): ApprovalAdapterQuery {
|
|
34
|
+
const item = fields(value, ["version", "toolName", "respond"]);
|
|
35
|
+
if (item.version !== 1 || typeof item.respond !== "function") throw new Error("Invalid approval adapter query");
|
|
36
|
+
toolName(item.toolName);
|
|
37
|
+
return value as ApprovalAdapterQuery;
|
|
38
|
+
}
|
|
39
|
+
export function validateAdapter(value: unknown): ApprovalAdapter {
|
|
40
|
+
const item = fields(value, ["version", "toolName", "parameters"]);
|
|
41
|
+
if (item.version !== 1 || !IsSchema(item.parameters)) throw new Error("Invalid approval adapter capability");
|
|
42
|
+
toolName(item.toolName);
|
|
43
|
+
return value as ApprovalAdapter;
|
|
44
|
+
}
|
|
45
|
+
/** Conservative known input shapes; not detection of arbitrary execution or mutation. */
|
|
46
|
+
export function requiresApprovalAdapter(input: unknown): boolean {
|
|
47
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return false;
|
|
48
|
+
if (Object.hasOwn(input, "command")) return true;
|
|
49
|
+
if (
|
|
50
|
+
Object.hasOwn(input, "path") &&
|
|
51
|
+
["content", "edits", "oldText", "newText", "old_text", "new_text"].some((key) => Object.hasOwn(input, key))
|
|
52
|
+
)
|
|
53
|
+
return true;
|
|
54
|
+
const nested = Object.getOwnPropertyDescriptor(input, "input");
|
|
55
|
+
return (
|
|
56
|
+
!!nested &&
|
|
57
|
+
"value" in nested &&
|
|
58
|
+
nested.value !== input &&
|
|
59
|
+
!!nested.value &&
|
|
60
|
+
typeof nested.value === "object" &&
|
|
61
|
+
(Object.hasOwn(nested.value, "command") ||
|
|
62
|
+
(Object.hasOwn(nested.value, "path") &&
|
|
63
|
+
["content", "edits", "oldText", "newText", "old_text", "new_text"].some((key) =>
|
|
64
|
+
Object.hasOwn(nested.value, key),
|
|
65
|
+
)))
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Known schema properties also require admission when optional inputs are
|
|
70
|
+
* filled by downstream normalizers. This is not arbitrary semantic detection.
|
|
71
|
+
* Only root properties and a single input-envelope are inspected, like inputs.
|
|
72
|
+
*/
|
|
73
|
+
export function schemaRequiresApprovalAdapter(parameters: TSchema): boolean {
|
|
74
|
+
const descriptor = Object.getOwnPropertyDescriptor(parameters, "properties");
|
|
75
|
+
const properties: unknown = descriptor && "value" in descriptor ? descriptor.value : undefined;
|
|
76
|
+
if (!properties || typeof properties !== "object" || Array.isArray(properties)) return false;
|
|
77
|
+
if (requiresApprovalAdapter(properties)) return true;
|
|
78
|
+
const envelope = Object.getOwnPropertyDescriptor(properties, "input");
|
|
79
|
+
if (!envelope || !("value" in envelope) || !envelope.value || typeof envelope.value !== "object") return false;
|
|
80
|
+
const nested = Object.getOwnPropertyDescriptor(envelope.value, "properties");
|
|
81
|
+
return !!nested && "value" in nested && requiresApprovalAdapter(nested.value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Trusted cooperative gate attestation, not authorization or an OS sandbox. */
|
|
85
|
+
export function queryApprovalAdapter(events: ApprovalEvents, name: string, parameters: TSchema): ApprovalAdapter {
|
|
86
|
+
toolName(name);
|
|
87
|
+
const responses: ApprovalAdapter[] = [];
|
|
88
|
+
let malformed = false;
|
|
89
|
+
let open = true;
|
|
90
|
+
try {
|
|
91
|
+
events.emit(
|
|
92
|
+
APPROVAL_ADAPTER_QUERY_CHANNEL,
|
|
93
|
+
Object.freeze({
|
|
94
|
+
version: 1,
|
|
95
|
+
toolName: name,
|
|
96
|
+
respond: (value: unknown) => {
|
|
97
|
+
if (!open) return;
|
|
98
|
+
try {
|
|
99
|
+
const adapter = validateAdapter(value);
|
|
100
|
+
if (adapter.toolName !== name || adapter.parameters !== parameters)
|
|
101
|
+
throw new Error("Adapter identity mismatch");
|
|
102
|
+
responses.push(adapter);
|
|
103
|
+
} catch {
|
|
104
|
+
malformed = true;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
} finally {
|
|
110
|
+
open = false;
|
|
111
|
+
}
|
|
112
|
+
if (malformed || responses.length !== 1)
|
|
113
|
+
throw new Error("Command tool requires exactly one live cooperative approval adapter");
|
|
114
|
+
return responses[0];
|
|
115
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// One invocation, one POSIX process group. Keeping its leader alive through
|
|
2
|
+
// shell exit/drain avoids ever signalling a released/reusable shell PID.
|
|
3
|
+
// IPC disconnect also reclaims the group when the host exits or is killed,
|
|
4
|
+
// without installing or changing signal handlers in the host.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { constants } from "node:os";
|
|
7
|
+
|
|
8
|
+
let started = false;
|
|
9
|
+
let finishing = false;
|
|
10
|
+
let timeout;
|
|
11
|
+
function killGroup() {
|
|
12
|
+
// This process is still the live group leader; never a saved foreign PID.
|
|
13
|
+
try {
|
|
14
|
+
process.kill(-process.pid, "SIGKILL");
|
|
15
|
+
} catch {
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function finish(result) {
|
|
20
|
+
if (finishing) return;
|
|
21
|
+
finishing = true;
|
|
22
|
+
clearTimeout(timeout);
|
|
23
|
+
if (!process.connected) return killGroup();
|
|
24
|
+
// Deliver the status before killing our own group. Output continues to flow
|
|
25
|
+
// through inherited stdout/stderr, with a bounded drain in the host.
|
|
26
|
+
process.send(result, () => killGroup());
|
|
27
|
+
}
|
|
28
|
+
process.on("disconnect", killGroup);
|
|
29
|
+
process.on("error", killGroup);
|
|
30
|
+
process.on("message", (message) => {
|
|
31
|
+
if (message === "cancel") return killGroup();
|
|
32
|
+
if (started || !process.connected) return killGroup();
|
|
33
|
+
started = true;
|
|
34
|
+
const { shell, args, commandTransport, command, cwd, env, seconds } = message;
|
|
35
|
+
try {
|
|
36
|
+
const stdin = commandTransport === "stdin";
|
|
37
|
+
const child = spawn(shell, stdin ? args : [...args, command], {
|
|
38
|
+
cwd,
|
|
39
|
+
env,
|
|
40
|
+
detached: false,
|
|
41
|
+
stdio: [stdin ? "pipe" : "ignore", 1, 2],
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
});
|
|
44
|
+
child.once("error", (error) => finish({ error: error.message }));
|
|
45
|
+
child.once("exit", (code, signal) => {
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
// Buffered output remains in the host pipe; descendants must not keep
|
|
48
|
+
// it open after the command exits. The host drains it before settling.
|
|
49
|
+
finish({ exitCode: code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1) });
|
|
50
|
+
});
|
|
51
|
+
if (stdin) {
|
|
52
|
+
child.stdin.on("error", () => {});
|
|
53
|
+
child.stdin.end(command);
|
|
54
|
+
}
|
|
55
|
+
if (seconds !== undefined) timeout = setTimeout(() => finish({ error: `timeout:${seconds}` }), seconds * 1000);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
finish({ error: error.message });
|
|
58
|
+
}
|
|
59
|
+
});
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const APPROVAL_VERSION = 1;
|
|
4
|
+
export const APPROVAL_QUERY_CHANNEL = "signalridge:approvals:query:v1";
|
|
5
|
+
export const APPROVAL_LIFECYCLE_CHANNEL = "signalridge:approvals:lifecycle:v1";
|
|
6
|
+
export const MAX_FILE_BYTES = 256 * 1024;
|
|
7
|
+
export const MAX_REVIEW_BYTES = 64 * 1024;
|
|
8
|
+
export const MAX_INPUT_BYTES = 256 * 1024;
|
|
9
|
+
|
|
10
|
+
export interface ApprovalEvents {
|
|
11
|
+
emit(channel: string, data: unknown): void;
|
|
12
|
+
}
|
|
13
|
+
export interface ApprovalRequestOptions {
|
|
14
|
+
readonly signal?: AbortSignal;
|
|
15
|
+
readonly isCurrent: () => boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ApprovalSnapshot {
|
|
18
|
+
readonly text: string;
|
|
19
|
+
readonly bytes: number;
|
|
20
|
+
readonly sha256: string;
|
|
21
|
+
}
|
|
22
|
+
interface OperationBase {
|
|
23
|
+
readonly source: string;
|
|
24
|
+
readonly toolName: string;
|
|
25
|
+
readonly cwd: string;
|
|
26
|
+
readonly toolCallId: string;
|
|
27
|
+
readonly inputHash: string;
|
|
28
|
+
readonly preview: string;
|
|
29
|
+
}
|
|
30
|
+
export type ApprovalOperation =
|
|
31
|
+
| (OperationBase & { readonly kind: "command"; readonly command: string })
|
|
32
|
+
| (OperationBase & {
|
|
33
|
+
readonly kind: "write" | "edit";
|
|
34
|
+
readonly toolName: "write" | "edit";
|
|
35
|
+
readonly filePath: string;
|
|
36
|
+
readonly before: ApprovalSnapshot | null;
|
|
37
|
+
readonly after: ApprovalSnapshot;
|
|
38
|
+
});
|
|
39
|
+
export interface ApprovalService {
|
|
40
|
+
readonly version: 1;
|
|
41
|
+
readonly id: string;
|
|
42
|
+
request(operation: ApprovalOperation, options: ApprovalRequestOptions): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
export interface ApprovalQuery {
|
|
45
|
+
readonly version: 1;
|
|
46
|
+
readonly respond: (service: unknown) => void;
|
|
47
|
+
}
|
|
48
|
+
export type ApprovalPhase = "waiting" | "respond" | "resolved";
|
|
49
|
+
export type ApprovalOutcome = "approved" | "denied" | "cancelled" | "retired" | "expired" | "error";
|
|
50
|
+
export interface ApprovalLifecycle {
|
|
51
|
+
readonly version: 1;
|
|
52
|
+
readonly serviceId: string;
|
|
53
|
+
readonly requestId: string;
|
|
54
|
+
readonly source: string;
|
|
55
|
+
readonly toolCallId: string;
|
|
56
|
+
readonly phase: ApprovalPhase;
|
|
57
|
+
readonly outcome?: ApprovalOutcome;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function record(value: unknown): Record<string, unknown> {
|
|
61
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid approval object");
|
|
62
|
+
const prototype = Object.getPrototypeOf(value);
|
|
63
|
+
if (prototype !== Object.prototype && prototype !== null) throw new Error("Invalid approval prototype");
|
|
64
|
+
if (Object.values(Object.getOwnPropertyDescriptors(value)).some((descriptor) => !("value" in descriptor)))
|
|
65
|
+
throw new Error("Invalid approval accessor");
|
|
66
|
+
return value as Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
function keys(value: Record<string, unknown>, allowed: string[]): void {
|
|
69
|
+
if (Reflect.ownKeys(value).some((key) => typeof key !== "string" || !allowed.includes(key)))
|
|
70
|
+
throw new Error("Unknown approval field");
|
|
71
|
+
}
|
|
72
|
+
export function boundedText(value: unknown, max: number): string {
|
|
73
|
+
if (
|
|
74
|
+
typeof value !== "string" ||
|
|
75
|
+
Buffer.byteLength(value) > max ||
|
|
76
|
+
/[\uD800-\uDFFF]/u.test(value.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/gu, ""))
|
|
77
|
+
) {
|
|
78
|
+
throw new Error("Invalid or oversized approval text");
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
export function approvalId(value: unknown): string {
|
|
83
|
+
const id = boundedText(value, 160);
|
|
84
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/u.test(id)) throw new Error("Invalid approval identity");
|
|
85
|
+
return id;
|
|
86
|
+
}
|
|
87
|
+
/** Provider-owned opaque identity: no normalization, truncation or ASCII restriction. */
|
|
88
|
+
export function approvalToolCallId(value: unknown): string {
|
|
89
|
+
const id = boundedText(value, 1024);
|
|
90
|
+
if (!id) throw new Error("Empty approval tool-call identity");
|
|
91
|
+
return id;
|
|
92
|
+
}
|
|
93
|
+
export function hashBytes(value: string | Buffer): string {
|
|
94
|
+
return createHash("sha256").update(value).digest("hex");
|
|
95
|
+
}
|
|
96
|
+
function hash(value: unknown): string {
|
|
97
|
+
if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) throw new Error("Invalid approval hash");
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
export function snapshot(buffer: Buffer): ApprovalSnapshot {
|
|
101
|
+
if (buffer.length > MAX_FILE_BYTES) throw new Error("File exceeds approval size limit");
|
|
102
|
+
const text = buffer.toString("utf8");
|
|
103
|
+
if (text.includes("\0") || !Buffer.from(text, "utf8").equals(buffer)) throw new Error("Binary or invalid UTF-8 file");
|
|
104
|
+
return Object.freeze({ text, bytes: buffer.length, sha256: hashBytes(buffer) });
|
|
105
|
+
}
|
|
106
|
+
function validateSnapshot(value: unknown): ApprovalSnapshot {
|
|
107
|
+
const item = record(value);
|
|
108
|
+
keys(item, ["text", "bytes", "sha256"]);
|
|
109
|
+
const verified = snapshot(Buffer.from(boundedText(item.text, MAX_FILE_BYTES)));
|
|
110
|
+
if (item.bytes !== verified.bytes || hash(item.sha256) !== verified.sha256) throw new Error("Invalid file snapshot");
|
|
111
|
+
return verified;
|
|
112
|
+
}
|
|
113
|
+
/** Escape display controls, including bidi/format characters, without removing changes. */
|
|
114
|
+
export function escapeDisplay(text: string): string {
|
|
115
|
+
return text
|
|
116
|
+
.replace(/\\/gu, "\\\\")
|
|
117
|
+
.replace(/[\p{C}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu, (char) =>
|
|
118
|
+
char === "\n" ? "\n" : `\\u{${char.codePointAt(0)?.toString(16)}}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
export function validateOperation(value: unknown): ApprovalOperation {
|
|
122
|
+
const item = record(value);
|
|
123
|
+
const base = {
|
|
124
|
+
source: approvalId(item.source),
|
|
125
|
+
cwd: boundedText(item.cwd, 4096),
|
|
126
|
+
toolCallId: approvalToolCallId(item.toolCallId),
|
|
127
|
+
inputHash: hash(item.inputHash),
|
|
128
|
+
preview: boundedText(item.preview, MAX_REVIEW_BYTES),
|
|
129
|
+
};
|
|
130
|
+
if (!base.cwd || !base.preview) throw new Error("Empty approval review");
|
|
131
|
+
if (/[\p{C}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u.test(base.preview.replace(/\n/gu, "")))
|
|
132
|
+
throw new Error("Approval preview contains unescaped display controls");
|
|
133
|
+
const common = ["source", "cwd", "toolCallId", "inputHash", "preview", "kind", "toolName"];
|
|
134
|
+
if (item.kind === "command") {
|
|
135
|
+
keys(item, [...common, "command"]);
|
|
136
|
+
const toolName = boundedText(item.toolName, 80);
|
|
137
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_.:-]*$/u.test(toolName)) throw new Error("Invalid approval tool name");
|
|
138
|
+
return Object.freeze({
|
|
139
|
+
...base,
|
|
140
|
+
kind: "command",
|
|
141
|
+
toolName,
|
|
142
|
+
command: boundedText(item.command, MAX_INPUT_BYTES),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if ((item.kind === "edit" || item.kind === "write") && item.toolName === item.kind) {
|
|
146
|
+
keys(item, [...common, "filePath", "before", "after"]);
|
|
147
|
+
const before = item.before === null ? null : validateSnapshot(item.before);
|
|
148
|
+
if (item.kind === "edit" && !before) throw new Error("Edit requires an original file");
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
...base,
|
|
151
|
+
kind: item.kind,
|
|
152
|
+
toolName: item.kind,
|
|
153
|
+
filePath: boundedText(item.filePath, 4096),
|
|
154
|
+
before,
|
|
155
|
+
after: validateSnapshot(item.after),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
throw new Error("Invalid approval operation kind");
|
|
159
|
+
}
|
|
160
|
+
export function validateLifecycle(value: unknown): ApprovalLifecycle {
|
|
161
|
+
const item = record(value);
|
|
162
|
+
keys(item, ["version", "serviceId", "requestId", "source", "toolCallId", "phase", "outcome"]);
|
|
163
|
+
if (
|
|
164
|
+
item.version !== 1 ||
|
|
165
|
+
typeof item.phase !== "string" ||
|
|
166
|
+
!["waiting", "respond", "resolved"].includes(item.phase) ||
|
|
167
|
+
(item.outcome !== undefined &&
|
|
168
|
+
(typeof item.outcome !== "string" ||
|
|
169
|
+
!["approved", "denied", "cancelled", "retired", "expired", "error"].includes(item.outcome))) ||
|
|
170
|
+
(item.phase === "waiting" ? item.outcome !== undefined : item.outcome === undefined)
|
|
171
|
+
)
|
|
172
|
+
throw new Error("Invalid approval lifecycle");
|
|
173
|
+
return Object.freeze({
|
|
174
|
+
version: 1,
|
|
175
|
+
serviceId: approvalId(item.serviceId),
|
|
176
|
+
requestId: approvalId(item.requestId),
|
|
177
|
+
source: approvalId(item.source),
|
|
178
|
+
toolCallId: approvalToolCallId(item.toolCallId),
|
|
179
|
+
phase: item.phase as ApprovalPhase,
|
|
180
|
+
...(item.outcome ? { outcome: item.outcome as ApprovalOutcome } : {}),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
export function validateQuery(value: unknown): ApprovalQuery {
|
|
184
|
+
const item = record(value);
|
|
185
|
+
keys(item, ["version", "respond"]);
|
|
186
|
+
if (item.version !== 1 || typeof item.respond !== "function") throw new Error("Invalid approval query");
|
|
187
|
+
return value as ApprovalQuery;
|
|
188
|
+
}
|
|
189
|
+
function validateService(value: unknown): ApprovalService {
|
|
190
|
+
const item = record(value);
|
|
191
|
+
keys(item, ["version", "id", "request"]);
|
|
192
|
+
if (item.version !== 1 || typeof item.request !== "function") throw new Error("Invalid approval service");
|
|
193
|
+
approvalId(item.id);
|
|
194
|
+
return value as ApprovalService;
|
|
195
|
+
}
|
|
196
|
+
function discover(events: ApprovalEvents): ApprovalService | undefined {
|
|
197
|
+
const peers: ApprovalService[] = [];
|
|
198
|
+
let malformed = false;
|
|
199
|
+
let open = true;
|
|
200
|
+
events.emit(
|
|
201
|
+
APPROVAL_QUERY_CHANNEL,
|
|
202
|
+
Object.freeze({
|
|
203
|
+
version: 1,
|
|
204
|
+
respond: (value: unknown) => {
|
|
205
|
+
if (!open) return;
|
|
206
|
+
try {
|
|
207
|
+
peers.push(validateService(value));
|
|
208
|
+
} catch {
|
|
209
|
+
malformed = true;
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
open = false;
|
|
215
|
+
if (malformed) throw new Error("Malformed approval peer");
|
|
216
|
+
if (peers.length > 1) throw new Error("Duplicate approval services");
|
|
217
|
+
return peers[0];
|
|
218
|
+
}
|
|
219
|
+
export function assertCurrent(options: ApprovalRequestOptions): void {
|
|
220
|
+
if (
|
|
221
|
+
!options ||
|
|
222
|
+
typeof options.isCurrent !== "function" ||
|
|
223
|
+
(options.signal !== undefined && !(options.signal instanceof AbortSignal))
|
|
224
|
+
)
|
|
225
|
+
throw new Error("Invalid approval owner options");
|
|
226
|
+
if (options.signal?.aborted || options.isCurrent() !== true) throw new Error("Approval owner is cancelled or stale");
|
|
227
|
+
}
|
|
228
|
+
/** Local trusted callbacks, not a wire protocol or a serialization/security boundary. */
|
|
229
|
+
export function queryApprovalService(events: ApprovalEvents): ApprovalService | undefined {
|
|
230
|
+
return captureApprovalService(events)?.service;
|
|
231
|
+
}
|
|
232
|
+
/** A launch may await after approval; retain the SAME peer guard through dispatch. */
|
|
233
|
+
export function captureApprovalService(
|
|
234
|
+
events: ApprovalEvents,
|
|
235
|
+
): { readonly service: ApprovalService; readonly assertCurrent: () => void } | undefined {
|
|
236
|
+
const peer = discover(events);
|
|
237
|
+
if (!peer) return undefined;
|
|
238
|
+
const id = peer.id;
|
|
239
|
+
const request = peer.request;
|
|
240
|
+
let retired = false;
|
|
241
|
+
const checkPeer = () => {
|
|
242
|
+
if (retired) throw new Error("Captured approval service retired");
|
|
243
|
+
try {
|
|
244
|
+
const current = discover(events);
|
|
245
|
+
if (!current || current !== peer || current.id !== id || current.request !== request)
|
|
246
|
+
throw new Error("Approval service unavailable or replaced");
|
|
247
|
+
} catch (error) {
|
|
248
|
+
retired = true;
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const service: ApprovalService = Object.freeze({
|
|
253
|
+
version: 1,
|
|
254
|
+
id,
|
|
255
|
+
request: async (operation: ApprovalOperation, options: ApprovalRequestOptions) => {
|
|
256
|
+
checkPeer();
|
|
257
|
+
assertCurrent(options);
|
|
258
|
+
const callerCurrent = options.isCurrent;
|
|
259
|
+
const owner = Object.freeze({
|
|
260
|
+
signal: options.signal,
|
|
261
|
+
isCurrent: () => {
|
|
262
|
+
checkPeer();
|
|
263
|
+
return callerCurrent() === true;
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
const response = request.call(peer, validateOperation(operation), owner);
|
|
267
|
+
if (!(response instanceof Promise) || (await response) !== undefined) {
|
|
268
|
+
retired = true;
|
|
269
|
+
throw new Error("Invalid approval service response");
|
|
270
|
+
}
|
|
271
|
+
checkPeer();
|
|
272
|
+
assertCurrent(owner);
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
return Object.freeze({ service, assertCurrent: checkPeer });
|
|
276
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, lstat, mkdir, open, realpath, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
type ApprovalOperation,
|
|
7
|
+
type ApprovalRequestOptions,
|
|
8
|
+
assertCurrent,
|
|
9
|
+
escapeDisplay,
|
|
10
|
+
MAX_FILE_BYTES,
|
|
11
|
+
MAX_REVIEW_BYTES,
|
|
12
|
+
snapshot,
|
|
13
|
+
validateOperation,
|
|
14
|
+
} from "./contract.js";
|
|
15
|
+
|
|
16
|
+
interface Identity {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly token: string | null;
|
|
19
|
+
}
|
|
20
|
+
interface Capture {
|
|
21
|
+
readonly bytes: Buffer | null;
|
|
22
|
+
readonly identities: readonly Identity[];
|
|
23
|
+
}
|
|
24
|
+
function missing(error: unknown): boolean {
|
|
25
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
26
|
+
}
|
|
27
|
+
async function identity(path: string, target: boolean): Promise<string | null> {
|
|
28
|
+
try {
|
|
29
|
+
const stat = await lstat(path);
|
|
30
|
+
if (target && (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1))
|
|
31
|
+
throw new Error("Approval target must be a regular non-aliased file");
|
|
32
|
+
if (!target && !stat.isDirectory() && !stat.isSymbolicLink()) throw new Error("Invalid approval ancestor");
|
|
33
|
+
if (target && stat.size > MAX_FILE_BYTES) throw new Error("File exceeds approval size limit");
|
|
34
|
+
return JSON.stringify([
|
|
35
|
+
await realpath(path),
|
|
36
|
+
stat.dev,
|
|
37
|
+
stat.ino,
|
|
38
|
+
stat.mode,
|
|
39
|
+
...(target ? [stat.size, stat.mtimeMs, stat.ctimeMs] : []),
|
|
40
|
+
]);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (missing(error)) return null;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function boundedRead(path: string): Promise<Buffer> {
|
|
47
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
48
|
+
try {
|
|
49
|
+
const stat = await handle.stat();
|
|
50
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
|
|
51
|
+
throw new Error("File exceeds approval size limit or is not regular");
|
|
52
|
+
const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
|
|
53
|
+
let count = 0;
|
|
54
|
+
while (count < buffer.length) {
|
|
55
|
+
const { bytesRead } = await handle.read(buffer, count, buffer.length - count, count);
|
|
56
|
+
if (!bytesRead) break;
|
|
57
|
+
count += bytesRead;
|
|
58
|
+
}
|
|
59
|
+
if (count > MAX_FILE_BYTES) throw new Error("File exceeds approval size limit");
|
|
60
|
+
const result = buffer.subarray(0, count);
|
|
61
|
+
snapshot(result);
|
|
62
|
+
return result;
|
|
63
|
+
} finally {
|
|
64
|
+
await handle.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function capture(path: string): Promise<Capture> {
|
|
68
|
+
const identities: Identity[] = [];
|
|
69
|
+
const token = await identity(path, true);
|
|
70
|
+
identities.push({ path, token });
|
|
71
|
+
let parent = dirname(path);
|
|
72
|
+
while (true) {
|
|
73
|
+
identities.push({ path: parent, token: await identity(parent, false) });
|
|
74
|
+
const next = dirname(parent);
|
|
75
|
+
if (next === parent) break;
|
|
76
|
+
parent = next;
|
|
77
|
+
}
|
|
78
|
+
const bytes = token === null ? null : await boundedRead(path);
|
|
79
|
+
const result = { bytes, identities };
|
|
80
|
+
await recheck(result);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
async function recheck(captured: Capture, directoriesCreated = false): Promise<void> {
|
|
84
|
+
for (const [index, item] of captured.identities.entries()) {
|
|
85
|
+
if (directoriesCreated && index > 0 && item.token === null) {
|
|
86
|
+
// These directories were absent during review and have just been created.
|
|
87
|
+
// This is not proof against an external writer racing mkdir/lstat/write.
|
|
88
|
+
if (!(await lstat(item.path)).isDirectory()) throw new Error("Created approval ancestor changed");
|
|
89
|
+
} else if ((await identity(item.path, index === 0)) !== item.token)
|
|
90
|
+
throw new Error("Approval target or ancestor changed");
|
|
91
|
+
}
|
|
92
|
+
const target = captured.identities[0];
|
|
93
|
+
if (!target) throw new Error("Missing approval capture");
|
|
94
|
+
if (captured.bytes && !(await boundedRead(target.path)).equals(captured.bytes))
|
|
95
|
+
throw new Error("Approval file contents changed");
|
|
96
|
+
}
|
|
97
|
+
export interface FileInvocation {
|
|
98
|
+
readonly kind: "write" | "edit";
|
|
99
|
+
readonly cwd: string;
|
|
100
|
+
readonly toolCallId: string;
|
|
101
|
+
readonly source: string;
|
|
102
|
+
readonly inputHash: string;
|
|
103
|
+
readonly options: ApprovalRequestOptions;
|
|
104
|
+
readonly request: (operation: ApprovalOperation, options: ApprovalRequestOptions) => Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
function review(invocation: FileInvocation, path: string, before: Buffer | null, after: Buffer): ApprovalOperation {
|
|
107
|
+
const prior = before ? snapshot(before) : null;
|
|
108
|
+
const next = snapshot(after);
|
|
109
|
+
// Myers line diff is quadratic for disjoint inputs. Bound tokens BEFORE
|
|
110
|
+
// entering the synchronous SDK diff; byte/payload limits alone cannot do so.
|
|
111
|
+
// split includes a terminal empty token, deliberately a conservative count.
|
|
112
|
+
if ((prior?.text ?? "").split("\n").length + next.text.split("\n").length > 2048)
|
|
113
|
+
throw new Error(
|
|
114
|
+
"File exceeds approval review computational limit (2048 combined line tokens); changes were not clipped",
|
|
115
|
+
);
|
|
116
|
+
// Diff the escaped text so BOM, CRLF and invisible changes are explicit.
|
|
117
|
+
const patch = generateUnifiedPatch(escapeDisplay(path), escapeDisplay(prior?.text ?? ""), escapeDisplay(next.text));
|
|
118
|
+
const preview = `Source: ${invocation.source}\nCWD: ${escapeDisplay(invocation.cwd)}\nFile: ${escapeDisplay(path)}\nEscaped display (backslashes doubled; controls shown as \\u{hex})\nBefore: ${prior?.sha256 ?? "missing"}\nAfter: ${next.sha256}\n${patch}`;
|
|
119
|
+
if (Buffer.byteLength(preview) > MAX_REVIEW_BYTES)
|
|
120
|
+
throw new Error("Generated review exceeds approval size limit; changes were not clipped");
|
|
121
|
+
return validateOperation({
|
|
122
|
+
kind: invocation.kind,
|
|
123
|
+
toolName: invocation.kind,
|
|
124
|
+
source: invocation.source,
|
|
125
|
+
cwd: invocation.cwd,
|
|
126
|
+
toolCallId: invocation.toolCallId,
|
|
127
|
+
inputHash: invocation.inputHash,
|
|
128
|
+
filePath: path,
|
|
129
|
+
before: prior,
|
|
130
|
+
after: next,
|
|
131
|
+
preview,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** Invocation-local operations. The SDK factory owns the only mutation queue. */
|
|
135
|
+
export function createFileOperations(invocation: FileInvocation) {
|
|
136
|
+
let original: Capture | undefined;
|
|
137
|
+
return {
|
|
138
|
+
access: (path: string) => access(path, constants.R_OK | constants.W_OK),
|
|
139
|
+
// SDK write calls mkdir before writeFile: deliberately defer it until approval.
|
|
140
|
+
mkdir: async (_dir: string): Promise<void> => {},
|
|
141
|
+
readFile: async (path: string): Promise<Buffer> => {
|
|
142
|
+
assertCurrent(invocation.options);
|
|
143
|
+
original = await capture(path);
|
|
144
|
+
assertCurrent(invocation.options);
|
|
145
|
+
if (!original.bytes) throw new Error("Edit target disappeared");
|
|
146
|
+
return Buffer.from(original.bytes);
|
|
147
|
+
},
|
|
148
|
+
writeFile: async (path: string, content: string): Promise<void> => {
|
|
149
|
+
assertCurrent(invocation.options);
|
|
150
|
+
const captured = invocation.kind === "edit" ? original : await capture(path);
|
|
151
|
+
if (!captured) throw new Error("Edit did not capture its input");
|
|
152
|
+
const after = Buffer.from(content, "utf8");
|
|
153
|
+
if (after.toString("utf8") !== content) throw new Error("Invalid UTF-8 output");
|
|
154
|
+
const operation = review(invocation, path, captured.bytes, after);
|
|
155
|
+
await invocation.request(operation, invocation.options);
|
|
156
|
+
assertCurrent(invocation.options);
|
|
157
|
+
await recheck(captured);
|
|
158
|
+
assertCurrent(invocation.options);
|
|
159
|
+
if (invocation.kind === "write") {
|
|
160
|
+
await mkdir(dirname(path), { recursive: true });
|
|
161
|
+
// Failure after mkdir may leave directories behind; no rollback promise.
|
|
162
|
+
assertCurrent(invocation.options);
|
|
163
|
+
await recheck(captured, true);
|
|
164
|
+
assertCurrent(invocation.options);
|
|
165
|
+
}
|
|
166
|
+
await writeFile(path, after);
|
|
167
|
+
assertCurrent(invocation.options);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access } from "node:fs/promises";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { type BashOperations, getAgentDir, getShellConfig } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { assertCurrent } from "./contract.js";
|
|
8
|
+
import { assertOwnedBashRuntime } from "./local-runtime.js";
|
|
9
|
+
|
|
10
|
+
/** Owned POSIX local backend; the SDK still owns tool/output/truncation behavior.
|
|
11
|
+
* A short-lived supervisor is the process-group lifetime anchor and IPC orphan
|
|
12
|
+
* reaper. No host signal handlers, private SDK helpers or saved-PID kills.
|
|
13
|
+
*/
|
|
14
|
+
export function createOwnedLocalBashOperations(isCurrent: () => boolean, shellPath?: string): BashOperations {
|
|
15
|
+
return {
|
|
16
|
+
async exec(command, cwd, execution) {
|
|
17
|
+
assertOwnedBashRuntime();
|
|
18
|
+
const { signal, timeout, onData } = execution;
|
|
19
|
+
if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0 || timeout * 1000 > 2147483647))
|
|
20
|
+
throw new Error("Invalid bash timeout");
|
|
21
|
+
const owner = { signal, isCurrent };
|
|
22
|
+
assertCurrent(owner);
|
|
23
|
+
const shell = getShellConfig(shellPath);
|
|
24
|
+
const env = { ...(execution.env ?? process.env) };
|
|
25
|
+
if (execution.env === undefined) {
|
|
26
|
+
const key = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
|
27
|
+
const bin = join(getAgentDir(), "bin");
|
|
28
|
+
const path = env[key] ?? "";
|
|
29
|
+
if (!path.split(delimiter).includes(bin)) env[key] = [bin, path].filter(Boolean).join(delimiter);
|
|
30
|
+
}
|
|
31
|
+
const payload = { ...shell, args: [...shell.args], command, cwd, env, seconds: timeout };
|
|
32
|
+
try {
|
|
33
|
+
await access(cwd, constants.F_OK);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
|
|
36
|
+
}
|
|
37
|
+
// Avoid allocating a supervisor for an owner already lost during CWD IO.
|
|
38
|
+
// User-command dispatch is separately guarded at the synchronous IPC send.
|
|
39
|
+
assertCurrent(owner);
|
|
40
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL("./bash-supervisor.mjs", import.meta.url))], {
|
|
41
|
+
cwd,
|
|
42
|
+
env: {},
|
|
43
|
+
detached: true,
|
|
44
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
45
|
+
windowsHide: true,
|
|
46
|
+
});
|
|
47
|
+
return new Promise<{ exitCode: number | null }>((resolve, reject) => {
|
|
48
|
+
let result: { exitCode?: number; error?: string } | undefined;
|
|
49
|
+
let failure: unknown;
|
|
50
|
+
let settled = false;
|
|
51
|
+
let drain: ReturnType<typeof setTimeout> | undefined;
|
|
52
|
+
const disconnect = () => {
|
|
53
|
+
if (child.connected) child.disconnect();
|
|
54
|
+
};
|
|
55
|
+
const onAbort = () => disconnect(); // supervisor kills only its STILL-OWNED group
|
|
56
|
+
const onDataSafe = (data: Buffer) => {
|
|
57
|
+
if (failure || settled) return;
|
|
58
|
+
try {
|
|
59
|
+
onData(data);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
failure = error instanceof Error ? error : new Error("Bash output callback failed", { cause: error });
|
|
62
|
+
disconnect();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const onMessage = (value: unknown) => {
|
|
66
|
+
if (!value || typeof value !== "object") return;
|
|
67
|
+
if ("error" in value && typeof value.error === "string") result = { error: value.error };
|
|
68
|
+
else if ("exitCode" in value && typeof value.exitCode === "number") result = { exitCode: value.exitCode };
|
|
69
|
+
};
|
|
70
|
+
const finish = () => {
|
|
71
|
+
if (settled) return;
|
|
72
|
+
settled = true;
|
|
73
|
+
clearTimeout(drain);
|
|
74
|
+
signal?.removeEventListener("abort", onAbort);
|
|
75
|
+
child.removeListener("message", onMessage);
|
|
76
|
+
child.removeListener("error", onError);
|
|
77
|
+
child.removeListener("exit", onExit);
|
|
78
|
+
child.removeListener("close", finish);
|
|
79
|
+
child.stdout?.removeListener("data", onDataSafe);
|
|
80
|
+
child.stderr?.removeListener("data", onDataSafe);
|
|
81
|
+
child.stdout?.destroy();
|
|
82
|
+
child.stderr?.destroy();
|
|
83
|
+
disconnect();
|
|
84
|
+
if (failure) reject(failure);
|
|
85
|
+
else if (signal?.aborted) reject(new Error("aborted"));
|
|
86
|
+
else if (result?.error) reject(new Error(result.error));
|
|
87
|
+
else if (result?.exitCode !== undefined) resolve({ exitCode: result.exitCode });
|
|
88
|
+
else reject(new Error("Owned bash supervisor terminated without a result"));
|
|
89
|
+
};
|
|
90
|
+
const onError = (error: Error) => {
|
|
91
|
+
failure = error;
|
|
92
|
+
disconnect();
|
|
93
|
+
if (!child.pid) finish();
|
|
94
|
+
};
|
|
95
|
+
const onExit = () => {
|
|
96
|
+
// No saved PID remains eligible for signalling after exit. Even
|
|
97
|
+
// descendants that escaped the group cannot hold our pipes forever.
|
|
98
|
+
signal?.removeEventListener("abort", onAbort);
|
|
99
|
+
drain = setTimeout(finish, 250);
|
|
100
|
+
};
|
|
101
|
+
child.on("message", onMessage);
|
|
102
|
+
child.once("error", onError);
|
|
103
|
+
child.once("exit", onExit);
|
|
104
|
+
child.once("close", finish);
|
|
105
|
+
child.stdout?.on("data", onDataSafe);
|
|
106
|
+
child.stderr?.on("data", onDataSafe);
|
|
107
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
108
|
+
if (signal?.aborted) onAbort();
|
|
109
|
+
else {
|
|
110
|
+
try {
|
|
111
|
+
assertCurrent(owner); // The helper has not received a user command before this dispatch.
|
|
112
|
+
child.send(payload, (error) => {
|
|
113
|
+
if (error) onError(error);
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
onError(error instanceof Error ? error : new Error("Bash IPC dispatch failed", { cause: error }));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Do not execute a compiled application as though it were a JS interpreter.
|
|
2
|
+
* This pure capability check never probes by launching process.execPath and
|
|
3
|
+
* leaves edit/write factories usable on hosts whose bash runtime is refused. */
|
|
4
|
+
export function assertOwnedBashRuntime(): void {
|
|
5
|
+
const unavailable = () => {
|
|
6
|
+
throw new Error(
|
|
7
|
+
"Reviewed local bash requires a POSIX standalone Node.js runtime with public SEA detection; Bun and compiled application runtimes are unsupported",
|
|
8
|
+
);
|
|
9
|
+
};
|
|
10
|
+
if (process.platform === "win32" || process.versions.bun || typeof process.getBuiltinModule !== "function")
|
|
11
|
+
unavailable();
|
|
12
|
+
const probe: unknown = process.getBuiltinModule("node:sea");
|
|
13
|
+
if (
|
|
14
|
+
!probe ||
|
|
15
|
+
typeof probe !== "object" ||
|
|
16
|
+
!("isSea" in probe) ||
|
|
17
|
+
typeof probe.isSea !== "function" ||
|
|
18
|
+
probe.isSea() !== false
|
|
19
|
+
)
|
|
20
|
+
unavailable();
|
|
21
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type BashOperations,
|
|
3
|
+
createBashToolDefinition,
|
|
4
|
+
createEditToolDefinition,
|
|
5
|
+
createWriteToolDefinition,
|
|
6
|
+
type ExtensionToolContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { TSchema } from "typebox";
|
|
9
|
+
import { Value } from "typebox/value";
|
|
10
|
+
import {
|
|
11
|
+
type ApprovalOperation,
|
|
12
|
+
type ApprovalRequestOptions,
|
|
13
|
+
approvalId,
|
|
14
|
+
approvalToolCallId,
|
|
15
|
+
assertCurrent,
|
|
16
|
+
boundedText,
|
|
17
|
+
escapeDisplay,
|
|
18
|
+
hashBytes,
|
|
19
|
+
MAX_INPUT_BYTES,
|
|
20
|
+
validateOperation,
|
|
21
|
+
} from "./contract.js";
|
|
22
|
+
import { createFileOperations } from "./files.js";
|
|
23
|
+
import { createOwnedLocalBashOperations } from "./local-bash.js";
|
|
24
|
+
import { assertOwnedBashRuntime } from "./local-runtime.js";
|
|
25
|
+
|
|
26
|
+
export interface ApprovalToolOptions {
|
|
27
|
+
readonly source: string;
|
|
28
|
+
readonly request: (operation: ApprovalOperation, options: ApprovalRequestOptions) => Promise<void>;
|
|
29
|
+
/** Called against this invocation's captured context, before and after every gate. Errors deny. */
|
|
30
|
+
readonly isCurrent: (ctx: ExtensionToolContext) => boolean;
|
|
31
|
+
/** Optional live exact-name admission; rechecked through file IO and bash dispatch. */
|
|
32
|
+
readonly isToolCurrent?: (ctx: ExtensionToolContext, name: "edit" | "write" | "bash") => boolean;
|
|
33
|
+
readonly shellPath?: string;
|
|
34
|
+
readonly commandPrefix?: string;
|
|
35
|
+
}
|
|
36
|
+
function frozenInput<T>(input: T, schema: TSchema): T {
|
|
37
|
+
if (!Value.Check(schema, input)) throw new Error("Invalid final tool input schema");
|
|
38
|
+
const serialized = JSON.stringify(input);
|
|
39
|
+
boundedText(serialized, MAX_INPUT_BYTES);
|
|
40
|
+
const copy: T = JSON.parse(serialized);
|
|
41
|
+
function freeze(value: unknown): void {
|
|
42
|
+
if (value && typeof value === "object") {
|
|
43
|
+
for (const child of Object.values(value)) freeze(child);
|
|
44
|
+
Object.freeze(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
freeze(copy);
|
|
48
|
+
return copy;
|
|
49
|
+
}
|
|
50
|
+
export function createApprovalBashOperations(options: {
|
|
51
|
+
readonly source: string;
|
|
52
|
+
readonly toolCallId: string;
|
|
53
|
+
readonly inputHash: string;
|
|
54
|
+
readonly request: ApprovalToolOptions["request"];
|
|
55
|
+
readonly isCurrent: () => boolean;
|
|
56
|
+
readonly shellPath?: string;
|
|
57
|
+
}): BashOperations {
|
|
58
|
+
options = Object.freeze({ ...options });
|
|
59
|
+
const delegate = createOwnedLocalBashOperations(options.isCurrent, options.shellPath);
|
|
60
|
+
return {
|
|
61
|
+
exec: async (command, cwd, execution) => {
|
|
62
|
+
assertOwnedBashRuntime(); // Refuse compiled/unknown runtimes before asking a human or spawning.
|
|
63
|
+
// Own backend options as well as tool input: callers may retain and mutate
|
|
64
|
+
// the options/env while human review is pending. Keep signal identity.
|
|
65
|
+
execution = Object.freeze({
|
|
66
|
+
...execution,
|
|
67
|
+
...(execution.env === undefined ? {} : { env: Object.freeze({ ...execution.env }) }),
|
|
68
|
+
});
|
|
69
|
+
// Gate the final command/cwd received by the native backend, not tool_call input.
|
|
70
|
+
const owner = { signal: execution.signal, isCurrent: options.isCurrent };
|
|
71
|
+
assertCurrent(owner);
|
|
72
|
+
const operation = validateOperation({
|
|
73
|
+
kind: "command",
|
|
74
|
+
toolName: "bash",
|
|
75
|
+
source: options.source,
|
|
76
|
+
toolCallId: options.toolCallId,
|
|
77
|
+
inputHash: hashBytes(
|
|
78
|
+
JSON.stringify({ inputHash: options.inputHash, command, cwd, timeout: execution.timeout }),
|
|
79
|
+
),
|
|
80
|
+
command,
|
|
81
|
+
cwd,
|
|
82
|
+
preview: `Source: ${options.source}\nCWD: ${escapeDisplay(cwd)}\nExact command (escaped controls, doubled backslashes):\n${escapeDisplay(command)}`,
|
|
83
|
+
});
|
|
84
|
+
await options.request(operation, owner);
|
|
85
|
+
assertCurrent(owner);
|
|
86
|
+
return delegate.exec(command, cwd, execution);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Definitions work with registerTool or SDK customTools. No registrations occur here. */
|
|
91
|
+
export function createApprovalToolDefinitions(options: ApprovalToolOptions) {
|
|
92
|
+
options = Object.freeze({ ...options });
|
|
93
|
+
const source = approvalId(options.source);
|
|
94
|
+
const editBase = createEditToolDefinition("");
|
|
95
|
+
const writeBase = createWriteToolDefinition("");
|
|
96
|
+
const bashBase = createBashToolDefinition("");
|
|
97
|
+
const edit: typeof editBase = {
|
|
98
|
+
...editBase,
|
|
99
|
+
// No IO/diff on the render path; complete diffs belong to human review.
|
|
100
|
+
renderCall: () => ({
|
|
101
|
+
render: (width) => ["edit (human approval required)".slice(0, Math.max(0, Math.floor(width)))],
|
|
102
|
+
invalidate() {},
|
|
103
|
+
}),
|
|
104
|
+
async execute(id, params, signal, update, ctx) {
|
|
105
|
+
const input = frozenInput(params, editBase.parameters);
|
|
106
|
+
if (
|
|
107
|
+
typeof input.path !== "string" ||
|
|
108
|
+
!Array.isArray(input.edits) ||
|
|
109
|
+
!input.edits.length ||
|
|
110
|
+
input.edits.some(
|
|
111
|
+
(item) =>
|
|
112
|
+
typeof item.oldText !== "string" ||
|
|
113
|
+
typeof item.newText !== "string" ||
|
|
114
|
+
Object.keys(item).some((key) => !["oldText", "newText"].includes(key)),
|
|
115
|
+
) ||
|
|
116
|
+
Object.keys(input).some((key) => !["path", "edits"].includes(key))
|
|
117
|
+
)
|
|
118
|
+
throw new Error("Invalid final edit input");
|
|
119
|
+
const cwd = boundedText(ctx.cwd, 4096);
|
|
120
|
+
const owner = {
|
|
121
|
+
signal,
|
|
122
|
+
isCurrent: () =>
|
|
123
|
+
options.isCurrent(ctx) &&
|
|
124
|
+
(options.isToolCurrent === undefined || options.isToolCurrent(ctx, "edit") === true),
|
|
125
|
+
};
|
|
126
|
+
assertCurrent(owner);
|
|
127
|
+
const operations = createFileOperations({
|
|
128
|
+
kind: "edit",
|
|
129
|
+
cwd,
|
|
130
|
+
toolCallId: approvalToolCallId(id),
|
|
131
|
+
source,
|
|
132
|
+
inputHash: hashBytes(JSON.stringify(input)),
|
|
133
|
+
options: owner,
|
|
134
|
+
request: options.request,
|
|
135
|
+
});
|
|
136
|
+
return createEditToolDefinition(cwd, { operations }).execute(id, input, signal, update, ctx);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const write: typeof writeBase = {
|
|
140
|
+
...writeBase,
|
|
141
|
+
async execute(id, params, signal, update, ctx) {
|
|
142
|
+
const input = frozenInput(params, writeBase.parameters);
|
|
143
|
+
if (
|
|
144
|
+
typeof input.path !== "string" ||
|
|
145
|
+
typeof input.content !== "string" ||
|
|
146
|
+
Object.keys(input).some((key) => !["path", "content"].includes(key))
|
|
147
|
+
)
|
|
148
|
+
throw new Error("Invalid final write input");
|
|
149
|
+
const cwd = boundedText(ctx.cwd, 4096);
|
|
150
|
+
const owner = {
|
|
151
|
+
signal,
|
|
152
|
+
isCurrent: () =>
|
|
153
|
+
options.isCurrent(ctx) &&
|
|
154
|
+
(options.isToolCurrent === undefined || options.isToolCurrent(ctx, "write") === true),
|
|
155
|
+
};
|
|
156
|
+
assertCurrent(owner);
|
|
157
|
+
const operations = createFileOperations({
|
|
158
|
+
kind: "write",
|
|
159
|
+
cwd,
|
|
160
|
+
toolCallId: approvalToolCallId(id),
|
|
161
|
+
source,
|
|
162
|
+
inputHash: hashBytes(JSON.stringify(input)),
|
|
163
|
+
options: owner,
|
|
164
|
+
request: options.request,
|
|
165
|
+
});
|
|
166
|
+
return createWriteToolDefinition(cwd, { operations }).execute(id, input, signal, update, ctx);
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
const bash: typeof bashBase = {
|
|
170
|
+
...bashBase,
|
|
171
|
+
async execute(id, params, signal, update, ctx) {
|
|
172
|
+
const input = frozenInput(params, bashBase.parameters);
|
|
173
|
+
if (
|
|
174
|
+
typeof input.command !== "string" ||
|
|
175
|
+
(input.timeout !== undefined &&
|
|
176
|
+
(!Number.isFinite(input.timeout) || input.timeout <= 0 || input.timeout > 2147483.647)) ||
|
|
177
|
+
Object.keys(input).some((key) => !["command", "timeout"].includes(key))
|
|
178
|
+
)
|
|
179
|
+
throw new Error("Invalid final bash input");
|
|
180
|
+
const cwd = boundedText(ctx.cwd, 4096);
|
|
181
|
+
const operations = createApprovalBashOperations({
|
|
182
|
+
source,
|
|
183
|
+
toolCallId: approvalToolCallId(id),
|
|
184
|
+
inputHash: hashBytes(JSON.stringify(input)),
|
|
185
|
+
request: options.request,
|
|
186
|
+
isCurrent: () =>
|
|
187
|
+
options.isCurrent(ctx) &&
|
|
188
|
+
(options.isToolCurrent === undefined || options.isToolCurrent(ctx, "bash") === true),
|
|
189
|
+
shellPath: options.shellPath,
|
|
190
|
+
});
|
|
191
|
+
return createBashToolDefinition(cwd, {
|
|
192
|
+
operations,
|
|
193
|
+
commandPrefix: options.commandPrefix,
|
|
194
|
+
shellPath: options.shellPath,
|
|
195
|
+
}).execute(id, input, signal, update, ctx);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
return [edit, write, bash] as const;
|
|
199
|
+
}
|