@arnilo/prism-tools 0.0.0-stage → 0.13.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 +410 -0
- package/LICENSE +21 -0
- package/README.md +44 -2
- package/browser/README.md +98 -0
- package/browser/skills/browser-automation/SKILL.md +14 -0
- package/browser/src/actions/cdp.ts +131 -0
- package/browser/src/actions/dialog.ts +25 -0
- package/browser/src/actions/download_release.ts +22 -0
- package/browser/src/actions/index.ts +37 -0
- package/browser/src/actions/locator.ts +70 -0
- package/browser/src/actions/navigate.ts +23 -0
- package/browser/src/actions/screenshot.ts +27 -0
- package/browser/src/actions/scroll.ts +24 -0
- package/browser/src/actions/select_page.ts +21 -0
- package/browser/src/actions/types.ts +114 -0
- package/browser/src/actions/upload.ts +44 -0
- package/browser/src/actions/wait.ts +45 -0
- package/browser/src/browser.ts +197 -0
- package/browser/src/cdp.ts +178 -0
- package/browser/src/checkpoint.ts +189 -0
- package/browser/src/config.ts +72 -0
- package/browser/src/downloads.ts +253 -0
- package/browser/src/errors.ts +31 -0
- package/browser/src/evaluate.ts +101 -0
- package/browser/src/index.ts +129 -0
- package/browser/src/limits.ts +271 -0
- package/browser/src/manager.ts +730 -0
- package/browser/src/network.ts +263 -0
- package/browser/src/observe.ts +178 -0
- package/browser/src/policy.ts +133 -0
- package/browser/src/screenshot.ts +107 -0
- package/browser/src/shared-sandbox.ts +53 -0
- package/browser/src/snapshot.ts +99 -0
- package/browser/src/targets.ts +165 -0
- package/browser/src/tools.ts +480 -0
- package/browser/src/types.ts +316 -0
- package/browser/src/uploads.ts +174 -0
- package/coding/README.md +337 -0
- package/coding/src/agent/acp-operations.ts +146 -0
- package/coding/src/agent/artifacts.ts +38 -0
- package/coding/src/agent/ask-user-decision.ts +623 -0
- package/coding/src/agent/atomic-write.ts +23 -0
- package/coding/src/agent/bounded-file.ts +21 -0
- package/coding/src/agent/checks.ts +301 -0
- package/coding/src/agent/coding-checkpoint.ts +828 -0
- package/coding/src/agent/delete.ts +237 -0
- package/coding/src/agent/diagnostics-tool.ts +163 -0
- package/coding/src/agent/diagnostics.ts +262 -0
- package/coding/src/agent/edit-diff.ts +382 -0
- package/coding/src/agent/edit.ts +322 -0
- package/coding/src/agent/effects.ts +102 -0
- package/coding/src/agent/env.ts +28 -0
- package/coding/src/agent/execution-policy.ts +89 -0
- package/coding/src/agent/file-mutation-queue.ts +72 -0
- package/coding/src/agent/forge/github.ts +603 -0
- package/coding/src/agent/forge/index.ts +16 -0
- package/coding/src/agent/forge/types.ts +185 -0
- package/coding/src/agent/git-aware-repository.ts +305 -0
- package/coding/src/agent/git-exec.ts +326 -0
- package/coding/src/agent/git-status.ts +177 -0
- package/coding/src/agent/git-tools.ts +360 -0
- package/coding/src/agent/git.ts +1074 -0
- package/coding/src/agent/glob-match.ts +146 -0
- package/coding/src/agent/glob.ts +182 -0
- package/coding/src/agent/goal-verify.ts +385 -0
- package/coding/src/agent/grep-worker.ts +74 -0
- package/coding/src/agent/grep.ts +459 -0
- package/coding/src/agent/identity.ts +48 -0
- package/coding/src/agent/index.ts +676 -0
- package/coding/src/agent/language/client.ts +429 -0
- package/coding/src/agent/language/framing.ts +190 -0
- package/coding/src/agent/language/index.ts +23 -0
- package/coding/src/agent/language/intelligence.ts +682 -0
- package/coding/src/agent/language/types.ts +187 -0
- package/coding/src/agent/lifecycle-events.ts +121 -0
- package/coding/src/agent/lifecycle.ts +166 -0
- package/coding/src/agent/limits.ts +214 -0
- package/coding/src/agent/move.ts +178 -0
- package/coding/src/agent/mutation-path.ts +52 -0
- package/coding/src/agent/output-accumulator.ts +304 -0
- package/coding/src/agent/path-utils.ts +120 -0
- package/coding/src/agent/process/index.ts +58 -0
- package/coding/src/agent/process/recovery.ts +508 -0
- package/coding/src/agent/process/sessions-host.ts +222 -0
- package/coding/src/agent/process/sessions-monitor.ts +141 -0
- package/coding/src/agent/process/sessions-recovery.ts +241 -0
- package/coding/src/agent/process/sessions-spawn.ts +613 -0
- package/coding/src/agent/process/sessions-teardown.ts +281 -0
- package/coding/src/agent/process/sessions.ts +30 -0
- package/coding/src/agent/process/types.ts +358 -0
- package/coding/src/agent/read-path-set.ts +126 -0
- package/coding/src/agent/read.ts +713 -0
- package/coding/src/agent/repository/glob.ts +147 -0
- package/coding/src/agent/repository/indexed-search.ts +480 -0
- package/coding/src/agent/repository/list.ts +131 -0
- package/coding/src/agent/repository/operations.ts +22 -0
- package/coding/src/agent/repository/path.ts +98 -0
- package/coding/src/agent/repository/search.ts +277 -0
- package/coding/src/agent/repository/types.ts +243 -0
- package/coding/src/agent/repository/walk.ts +126 -0
- package/coding/src/agent/repository.ts +12 -0
- package/coding/src/agent/shell-background.ts +264 -0
- package/coding/src/agent/shell-output.ts +75 -0
- package/coding/src/agent/shell.ts +526 -0
- package/coding/src/agent/todo.ts +399 -0
- package/coding/src/agent/truncate.ts +251 -0
- package/coding/src/agent/write.ts +170 -0
- package/coding/src/ask-user-host.ts +48 -0
- package/coding/src/config.ts +278 -0
- package/coding/src/hard-deny.ts +97 -0
- package/coding/src/index.ts +318 -0
- package/coding/src/principal-guard.ts +84 -0
- package/coding/src/sandbox.ts +92 -0
- package/coding/src/security/approval.ts +144 -0
- package/coding/src/security/command-rules.ts +57 -0
- package/coding/src/security/docker-cli.ts +222 -0
- package/coding/src/security/docker-sandbox.ts +1205 -0
- package/coding/src/security/e2b-sandbox.ts +853 -0
- package/coding/src/security/egress/dns-pin.ts +88 -0
- package/coding/src/security/egress/index.ts +32 -0
- package/coding/src/security/egress/limits.ts +57 -0
- package/coding/src/security/egress/policy.ts +137 -0
- package/coding/src/security/egress/proxy.ts +599 -0
- package/coding/src/security/egress/types.ts +30 -0
- package/coding/src/security/index.ts +199 -0
- package/coding/src/security/native-sandbox.ts +639 -0
- package/coding/src/security/path-containment.ts +55 -0
- package/coding/src/security/sandbox-coding-operations.ts +392 -0
- package/coding/src/security/sandbox-fs-operations.ts +644 -0
- package/coding/src/security/sandbox-limits.ts +147 -0
- package/coding/src/security/sandbox-tar.ts +308 -0
- package/coding/src/security/sandbox.ts +179 -0
- package/coding/src/security/semaphore.ts +55 -0
- package/coding/src/targets.ts +110 -0
- package/coding/src/workspace/diff.ts +78 -0
- package/coding/src/workspace/errors.ts +28 -0
- package/coding/src/workspace/index.ts +5 -0
- package/coding/src/workspace/merge.ts +151 -0
- package/coding/src/workspace/routing.ts +76 -0
- package/coding/src/workspace/service.ts +545 -0
- package/computer-use/README.md +105 -0
- package/computer-use/skills/computer-use-linux/SKILL.md +17 -0
- package/computer-use/src/classify.ts +53 -0
- package/computer-use/src/command.ts +29 -0
- package/computer-use/src/config.ts +69 -0
- package/computer-use/src/create.ts +170 -0
- package/computer-use/src/devices.ts +247 -0
- package/computer-use/src/env.ts +28 -0
- package/computer-use/src/index.ts +157 -0
- package/computer-use/src/skill.ts +21 -0
- package/openapi/README.md +97 -0
- package/openapi/src/compile.ts +386 -0
- package/openapi/src/config.ts +73 -0
- package/openapi/src/errors.ts +16 -0
- package/openapi/src/execute.ts +241 -0
- package/openapi/src/index.ts +124 -0
- package/openapi/src/limits.ts +63 -0
- package/openapi/src/specs.ts +86 -0
- package/openapi/src/tools.ts +60 -0
- package/openapi/src/types.ts +61 -0
- package/package.json +171 -4
- package/research/README.md +72 -0
- package/research/skills/research-report/SKILL.md +12 -0
- package/research/skills/research-scoping/SKILL.md +10 -0
- package/research/skills/research-synthesis/SKILL.md +10 -0
- package/research/skills/source-triage/SKILL.md +10 -0
- package/research/src/check.ts +71 -0
- package/research/src/config.ts +41 -0
- package/research/src/index.ts +147 -0
- package/research/src/ledger.ts +157 -0
- package/research/src/urls.ts +21 -0
- package/web/README.md +287 -0
- package/web/skills/web-research/SKILL.md +17 -0
- package/web/src/brave.ts +66 -0
- package/web/src/cache.ts +151 -0
- package/web/src/config.ts +210 -0
- package/web/src/controls.ts +128 -0
- package/web/src/domains.ts +114 -0
- package/web/src/exa.ts +60 -0
- package/web/src/firecrawl.ts +160 -0
- package/web/src/index.ts +387 -0
- package/web/src/kagi.ts +73 -0
- package/web/src/limits.ts +55 -0
- package/web/src/native/convert.ts +84 -0
- package/web/src/native/fetch.ts +226 -0
- package/web/src/normalize.ts +131 -0
- package/web/src/obscura/cdp.ts +201 -0
- package/web/src/obscura/classify.ts +35 -0
- package/web/src/obscura/cli.ts +254 -0
- package/web/src/obscura/errors.ts +8 -0
- package/web/src/obscura/index.ts +59 -0
- package/web/src/obscura/limits.ts +92 -0
- package/web/src/obscura/mcp.ts +104 -0
- package/web/src/obscura/playwright.ts +133 -0
- package/web/src/obscura/process.ts +235 -0
- package/web/src/obscura/search-profile.ts +73 -0
- package/web/src/obscura/types.ts +48 -0
- package/web/src/obscura/web.ts +419 -0
- package/web/src/prompt-fetch.ts +88 -0
- package/web/src/render.ts +75 -0
- package/web/src/searxng.ts +78 -0
- package/web/src/slice.ts +139 -0
- package/web/src/tavily.ts +77 -0
- package/web/src/tools.ts +182 -0
- package/web/src/transport.ts +310 -0
- package/web/src/types.ts +199 -0
- package/work/README.md +77 -0
- package/work/THIRD_PARTY_NOTICES +24 -0
- package/work/docling/ocr.py +150 -0
- package/work/sandbox/Dockerfile +36 -0
- package/work/sandbox/soffice.sh +12 -0
- package/work/src/command.ts +29 -0
- package/work/src/config.ts +138 -0
- package/work/src/connectors/cli.ts +343 -0
- package/work/src/connectors/drafts.ts +647 -0
- package/work/src/connectors/errors.ts +8 -0
- package/work/src/connectors/file-bytes.ts +149 -0
- package/work/src/connectors/google-workspace-http.ts +399 -0
- package/work/src/connectors/google-workspace.ts +465 -0
- package/work/src/connectors/gws-tools.ts +378 -0
- package/work/src/connectors/http.ts +123 -0
- package/work/src/connectors/idempotency.ts +235 -0
- package/work/src/connectors/identity.ts +34 -0
- package/work/src/connectors/index.ts +93 -0
- package/work/src/connectors/limits.ts +50 -0
- package/work/src/connectors/m365-tools.ts +308 -0
- package/work/src/connectors/microsoft365-http.ts +428 -0
- package/work/src/connectors/microsoft365.ts +402 -0
- package/work/src/connectors/normalize.ts +120 -0
- package/work/src/connectors/tool-helpers.ts +398 -0
- package/work/src/connectors/tools.ts +42 -0
- package/work/src/connectors/types.ts +399 -0
- package/work/src/diagrams/canonicalize.ts +166 -0
- package/work/src/diagrams/caps.ts +52 -0
- package/work/src/diagrams/embed.ts +340 -0
- package/work/src/diagrams/errors.ts +76 -0
- package/work/src/diagrams/index.ts +81 -0
- package/work/src/diagrams/messages.ts +150 -0
- package/work/src/diagrams/origin.ts +52 -0
- package/work/src/diagrams/telemetry.ts +34 -0
- package/work/src/diagrams/xml.ts +267 -0
- package/work/src/document-extraction/index.ts +563 -0
- package/work/src/document-reader/ambient.d.ts +2 -0
- package/work/src/document-reader/errors.ts +7 -0
- package/work/src/document-reader/index.ts +181 -0
- package/work/src/document-reader/mistral-ocr.ts +318 -0
- package/work/src/document-reader/parsers.ts +171 -0
- package/work/src/documents/caps.ts +135 -0
- package/work/src/documents/diff.ts +269 -0
- package/work/src/documents/errors.ts +44 -0
- package/work/src/documents/fidelity.ts +98 -0
- package/work/src/documents/generate.ts +96 -0
- package/work/src/documents/hash.ts +8 -0
- package/work/src/documents/index.ts +14 -0
- package/work/src/documents/model-schema.ts +639 -0
- package/work/src/documents/parse.ts +252 -0
- package/work/src/documents/patch-history.ts +81 -0
- package/work/src/documents/patch.ts +476 -0
- package/work/src/documents/preview-html.ts +245 -0
- package/work/src/documents/preview.ts +227 -0
- package/work/src/documents/telemetry.ts +36 -0
- package/work/src/documents/translate/docx.ts +292 -0
- package/work/src/documents/translate/pptx.ts +304 -0
- package/work/src/documents/translate/xlsx.ts +137 -0
- package/work/src/documents/types.ts +188 -0
- package/work/src/index.ts +311 -0
- package/work/src/runtime/worker-pool-entry.ts +102 -0
- package/work/src/runtime/worker-pool.ts +267 -0
- package/work/src/sandbox/composition.ts +212 -0
- package/work/src/sandbox/image.ts +6 -0
- package/work/src/sandbox/index.ts +14 -0
- package/work/src/sheets/caps.ts +68 -0
- package/work/src/sheets/csv.ts +351 -0
- package/work/src/sheets/decimal.ts +388 -0
- package/work/src/sheets/errors.ts +52 -0
- package/work/src/sheets/index.ts +62 -0
- package/work/src/sheets/inference.ts +378 -0
- package/work/src/sheets/telemetry.ts +34 -0
- package/work/src/sheets/types.ts +162 -0
- package/work/src/sheets/xlsx.ts +388 -0
- package/work/src/skills/index.ts +8 -0
- package/work/src/skills/load.ts +43 -0
- package/work/src/tools/filesystem.ts +21 -0
- package/work/src/tools/index.ts +1 -0
- package/work/src/tools/local-filesystem.ts +33 -0
- package/work/src/tools/office.ts +311 -0
- package/work/vendor/hermes-agent/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/SKILL.md +196 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_comments.py +289 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_common.py +94 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_create.py +177 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_edit.py +250 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_read.py +149 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_revisions.py +147 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_template.py +70 -0
- package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_validate.py +156 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/SKILL.md +125 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/_raster.py +76 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/extract_marker.py +88 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/extract_pymupdf.py +101 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_create.py +130 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_fill_form.py +97 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_form_layout.py +168 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_make_form.py +145 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_merge.py +50 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_meta.py +115 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_page_image.py +99 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_read.py +153 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_secure.py +71 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_split.py +84 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_stamp.py +143 -0
- package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_watermark.py +51 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/SKILL.md +220 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_create.py +214 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_edit.py +436 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_from_template.py +88 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_read.py +131 -0
- package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_render.py +93 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/LICENSE +21 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/SKILL.md +196 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/csv_to_xlsx.py +104 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_create.py +259 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_edit.py +263 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_read.py +160 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_recalc.py +111 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_restructure.py +337 -0
- package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_to_csv.py +69 -0
- package/work/vendor-lock.json +11 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit tool: precise text replacement in an existing file via exact matching.
|
|
3
|
+
*
|
|
4
|
+
* Behavioral port of pi's core/tools/edit for @arnilo/prism-tools/coding, adapted to Prism's
|
|
5
|
+
* `ToolDefinition` contract. Faithfully ports the access → read → stripBom → line-ending-normalize →
|
|
6
|
+
* `applyEditsToNormalizedContent` (exact only, with duplicate/overlap/empty/no-change guards) →
|
|
7
|
+
* write flow, serialized per-path via `withFileMutationQueue`. Also ports `prepareEditArguments`
|
|
8
|
+
* (tolerates models that send `edits` as a JSON string or as legacy top-level `oldText`/`newText`).
|
|
9
|
+
*
|
|
10
|
+
* Drops pi's TUI (`renderCall`/`renderResult`, live preview cache, theme/syntax-highlight).
|
|
11
|
+
*
|
|
12
|
+
* Deviations from pi (documented):
|
|
13
|
+
* - Abort + every failure (missing/unreadable file, no-match, duplicate, overlap, empty oldText,
|
|
14
|
+
* no-change) return a Prism `error` result (pi throws/rejects). On no-match the file is untouched
|
|
15
|
+
* because `applyEditsToNormalizedContent` throws before `writeFile`.
|
|
16
|
+
* - pi's per-tool `details: { diff, patch, firstChangedLine }` (TUI-facing) is surfaced as
|
|
17
|
+
* `ToolResult.metadata` (host-readable, keeps model context small — the model only sees the short
|
|
18
|
+
* `Edited <path>: N block(s).` confirmation plus the changed lines).
|
|
19
|
+
* - The post-`writeFile` abort check is dropped (consistent with the write tool): if the write
|
|
20
|
+
* completed, the edit is real and is reported as success rather than a misleading "aborted".
|
|
21
|
+
*/
|
|
22
|
+
import { Buffer } from "node:buffer";
|
|
23
|
+
import { constants } from "node:fs";
|
|
24
|
+
import { access as fsAccess, stat as fsStat } from "node:fs/promises";
|
|
25
|
+
import type { JsonObject, ToolDefinition, ToolExecutionContext, ToolResult } from "@arnilo/prism";
|
|
26
|
+
import { atomicWriteUtf8File } from "./atomic-write.js";
|
|
27
|
+
import { readFileBounded } from "./bounded-file.js";
|
|
28
|
+
import {
|
|
29
|
+
applyEditsToNormalizedContent,
|
|
30
|
+
detectLineEnding,
|
|
31
|
+
generateChangedLinePreview,
|
|
32
|
+
generateDiffString,
|
|
33
|
+
generateUnifiedPatch,
|
|
34
|
+
normalizeToLF,
|
|
35
|
+
restoreLineEndings,
|
|
36
|
+
stripBom,
|
|
37
|
+
} from "./edit-diff.js";
|
|
38
|
+
import { CODING_LOCAL_EFFECT } from "./effects.js";
|
|
39
|
+
import type { ExecutionPolicy } from "./execution-policy.js";
|
|
40
|
+
import { enforceExecutionPolicy } from "./execution-policy.js";
|
|
41
|
+
import { withFileMutationQueue } from "./file-mutation-queue.js";
|
|
42
|
+
import type { CodingLifecycleEvent } from "./lifecycle-events.js";
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_MAX_EDIT_FILE_BYTES,
|
|
45
|
+
DEFAULT_MAX_EDIT_INPUT_BYTES,
|
|
46
|
+
DEFAULT_MAX_EDITS,
|
|
47
|
+
HARD_MAX_EDIT_FILE_BYTES,
|
|
48
|
+
HARD_MAX_EDIT_INPUT_BYTES,
|
|
49
|
+
HARD_MAX_EDITS,
|
|
50
|
+
validateCodingLimit,
|
|
51
|
+
} from "./limits.js";
|
|
52
|
+
import { resolveToCwd } from "./path-utils.js";
|
|
53
|
+
import { type ReadBeforeWriteOptions, refuseReadBeforeWrite } from "./read-path-set.js";
|
|
54
|
+
|
|
55
|
+
export interface Edit {
|
|
56
|
+
oldText: string;
|
|
57
|
+
newText: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Display/result details mirrored from pi's `EditToolDetails`, surfaced via `ToolResult.metadata`. */
|
|
61
|
+
export interface EditToolDetails {
|
|
62
|
+
/** Absolute path written (F7 projection + host navigation). */
|
|
63
|
+
path: string;
|
|
64
|
+
/** Display-oriented diff of the changes made. */
|
|
65
|
+
diff: string;
|
|
66
|
+
/** Standard unified patch of the changes made. */
|
|
67
|
+
patch: string;
|
|
68
|
+
/** Line number of the first change in the new file (for editor navigation). */
|
|
69
|
+
firstChangedLine?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pluggable operations for the edit tool. Override to delegate file editing to remote systems
|
|
74
|
+
* (e.g. SSH) while keeping the tool's matching + per-path serialization behavior.
|
|
75
|
+
*/
|
|
76
|
+
export interface EditOperations {
|
|
77
|
+
/** Read bounded file contents as a Buffer. */
|
|
78
|
+
readFile: (absolutePath: string, options: { maxBytes: number; signal?: AbortSignal }) => Promise<Buffer>;
|
|
79
|
+
/** Write content to a file. */
|
|
80
|
+
writeFile: (absolutePath: string, content: string, options?: { signal?: AbortSignal }) => Promise<void>;
|
|
81
|
+
/** Check the file is readable and writable (throw if not). */
|
|
82
|
+
access: (absolutePath: string, options?: { signal?: AbortSignal }) => Promise<void>;
|
|
83
|
+
/** Return target size before the bounded read. */
|
|
84
|
+
statFile: (absolutePath: string, options?: { signal?: AbortSignal }) => Promise<{ size: number }>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface EditToolOptions extends ReadBeforeWriteOptions {
|
|
88
|
+
/** Structured pre-execution policy checked before filesystem writes. */
|
|
89
|
+
executionPolicy?: ExecutionPolicy;
|
|
90
|
+
/** Custom operations backend (default: local filesystem). */
|
|
91
|
+
operations?: EditOperations;
|
|
92
|
+
/** Maximum target file bytes read for matching (default 8 MiB). */
|
|
93
|
+
maxFileBytes?: number;
|
|
94
|
+
/** Maximum aggregate UTF-8 bytes across old/new edit text (default 2 MiB). */
|
|
95
|
+
maxInputBytes?: number;
|
|
96
|
+
/** Maximum replacements per call (default 100). */
|
|
97
|
+
maxEdits?: number;
|
|
98
|
+
/** Optional consumer-gated lifecycle listener (file_changed / permission_denied). */
|
|
99
|
+
onEvent?: (event: CodingLifecycleEvent) => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const defaultEditOperations: EditOperations = {
|
|
103
|
+
readFile: (path, options) => readFileBounded(path, options.maxBytes, options.signal),
|
|
104
|
+
writeFile: (path, content, options) => atomicWriteUtf8File(path, content, { signal: options?.signal }),
|
|
105
|
+
access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
|
|
106
|
+
statFile: async (path) => ({ size: (await fsStat(path)).size }),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function errorResult(toolCallId: string, message: string): ToolResult {
|
|
110
|
+
return {
|
|
111
|
+
toolCallId,
|
|
112
|
+
name: "edit",
|
|
113
|
+
content: [{ type: "text", text: message }],
|
|
114
|
+
error: { message },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Port of pi's `prepareEditArguments`: tolerate model quirks (edits as JSON string; legacy fields). */
|
|
119
|
+
function prepareEditArguments(input: JsonObject): { path: string; edits: unknown } {
|
|
120
|
+
let edits: unknown = input.edits;
|
|
121
|
+
// Some models send edits as a JSON string instead of an array.
|
|
122
|
+
if (typeof edits === "string") {
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(edits) as unknown;
|
|
125
|
+
if (Array.isArray(parsed)) edits = parsed;
|
|
126
|
+
} catch {
|
|
127
|
+
/* leave as-is; validation will reject */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const path = typeof input.path === "string" ? input.path : "";
|
|
131
|
+
// Legacy: top-level oldText/newText instead of edits[].
|
|
132
|
+
if (typeof input.oldText === "string" && typeof input.newText === "string") {
|
|
133
|
+
const arr = Array.isArray(edits) ? [...edits] : [];
|
|
134
|
+
arr.push({ oldText: input.oldText, newText: input.newText });
|
|
135
|
+
edits = arr;
|
|
136
|
+
}
|
|
137
|
+
return { path, edits };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function validateEdits(edits: unknown, maxEdits: number, maxInputBytes: number): Edit[] | string {
|
|
141
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
142
|
+
return "edits must contain at least one replacement.";
|
|
143
|
+
}
|
|
144
|
+
if (edits.length > maxEdits) return `edits contains ${edits.length} replacements, exceeds ${maxEdits} limit.`;
|
|
145
|
+
const out: Edit[] = [];
|
|
146
|
+
let inputBytes = 0;
|
|
147
|
+
for (let i = 0; i < edits.length; i++) {
|
|
148
|
+
const e = edits[i] as { oldText?: unknown; newText?: unknown };
|
|
149
|
+
if (typeof e?.oldText !== "string" || typeof e?.newText !== "string") {
|
|
150
|
+
return `edits[${i}] must have string oldText and newText.`;
|
|
151
|
+
}
|
|
152
|
+
inputBytes += Buffer.byteLength(e.oldText, "utf-8") + Buffer.byteLength(e.newText, "utf-8");
|
|
153
|
+
if (inputBytes > maxInputBytes) {
|
|
154
|
+
return `edit input is ${inputBytes} bytes, exceeds ${maxInputBytes} byte limit.`;
|
|
155
|
+
}
|
|
156
|
+
out.push({ oldText: e.oldText, newText: e.newText });
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createEditTool(cwd: string, options?: EditToolOptions): ToolDefinition {
|
|
162
|
+
const ops = options?.operations ?? defaultEditOperations;
|
|
163
|
+
const maxFileBytes = validateCodingLimit("maxFileBytes", options?.maxFileBytes ?? DEFAULT_MAX_EDIT_FILE_BYTES, HARD_MAX_EDIT_FILE_BYTES);
|
|
164
|
+
const maxInputBytes = validateCodingLimit(
|
|
165
|
+
"maxInputBytes",
|
|
166
|
+
options?.maxInputBytes ?? DEFAULT_MAX_EDIT_INPUT_BYTES,
|
|
167
|
+
HARD_MAX_EDIT_INPUT_BYTES,
|
|
168
|
+
);
|
|
169
|
+
const maxEdits = validateCodingLimit("maxEdits", options?.maxEdits ?? DEFAULT_MAX_EDITS, HARD_MAX_EDITS);
|
|
170
|
+
const guard = options?.requireReadBeforeWrite === true;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
name: "edit",
|
|
174
|
+
kind: "edit",
|
|
175
|
+
effect: CODING_LOCAL_EFFECT,
|
|
176
|
+
effects: ["write"],
|
|
177
|
+
description: `Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file exactly, including whitespace and newlines; copy it from \`read\` output without the line-number prefix. A miss fails with a Nearby hint and leaves the file unchanged. The result shows the changed lines with their new line numbers, so you can check the edit without another \`read\`. Duplicate or overlapping matches fail closed. If two changes affect the same block or nearby lines, merge them into one edit. Do not include large unchanged regions just to connect distant changes.${guard ? " Read the path first or pass force=true to override the host's read-before-write guard." : ""}`,
|
|
178
|
+
parameters: {
|
|
179
|
+
type: "object",
|
|
180
|
+
properties: {
|
|
181
|
+
path: { type: "string", description: "Path to the file to edit (relative or absolute)" },
|
|
182
|
+
...(guard
|
|
183
|
+
? {
|
|
184
|
+
force: {
|
|
185
|
+
type: "boolean",
|
|
186
|
+
description: "Bypass the read-before-write guard for this call.",
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
: {}),
|
|
190
|
+
edits: {
|
|
191
|
+
type: "array",
|
|
192
|
+
description:
|
|
193
|
+
"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
|
|
194
|
+
items: {
|
|
195
|
+
type: "object",
|
|
196
|
+
properties: {
|
|
197
|
+
oldText: {
|
|
198
|
+
type: "string",
|
|
199
|
+
description:
|
|
200
|
+
"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",
|
|
201
|
+
},
|
|
202
|
+
newText: { type: "string", description: "Replacement text for this targeted edit." },
|
|
203
|
+
},
|
|
204
|
+
required: ["oldText", "newText"],
|
|
205
|
+
additionalProperties: false,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
required: ["path", "edits"],
|
|
210
|
+
additionalProperties: false,
|
|
211
|
+
} as JsonObject,
|
|
212
|
+
async execute(args, context: ToolExecutionContext): Promise<ToolResult> {
|
|
213
|
+
const toolCallId = context.toolCallId;
|
|
214
|
+
if (typeof args.edits === "string" && Buffer.byteLength(args.edits, "utf-8") > maxInputBytes) {
|
|
215
|
+
return errorResult(toolCallId, `edit input exceeds ${maxInputBytes} byte limit.`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const prepared = prepareEditArguments(args);
|
|
219
|
+
const force = args.force === true;
|
|
220
|
+
if (prepared.path.length === 0) {
|
|
221
|
+
return errorResult(toolCallId, "path is required and must be a non-empty string.");
|
|
222
|
+
}
|
|
223
|
+
const editsOrError = validateEdits(prepared.edits, maxEdits, maxInputBytes);
|
|
224
|
+
if (typeof editsOrError === "string") {
|
|
225
|
+
return errorResult(toolCallId, editsOrError);
|
|
226
|
+
}
|
|
227
|
+
const edits = editsOrError;
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const absolutePath = resolveToCwd(prepared.path, cwd);
|
|
231
|
+
|
|
232
|
+
const policyCheck = await enforceExecutionPolicy(
|
|
233
|
+
options?.executionPolicy,
|
|
234
|
+
{
|
|
235
|
+
kind: "edit",
|
|
236
|
+
operation: "edit",
|
|
237
|
+
paths: [absolutePath],
|
|
238
|
+
risk: "medium",
|
|
239
|
+
metadata: { editCount: edits.length, sessionId: context.sessionId, runId: context.runId, signal: context.signal },
|
|
240
|
+
},
|
|
241
|
+
toolCallId,
|
|
242
|
+
"edit",
|
|
243
|
+
(denied) => options?.onEvent?.({ type: "permission_denied", ...denied }),
|
|
244
|
+
);
|
|
245
|
+
if (!policyCheck.allowed) return policyCheck.result;
|
|
246
|
+
const allowedPath = policyCheck.action.paths?.[0] ?? absolutePath;
|
|
247
|
+
|
|
248
|
+
const rbwRefusal = refuseReadBeforeWrite("edit", prepared.path, allowedPath, options, force);
|
|
249
|
+
if (rbwRefusal) return errorResult(toolCallId, rbwRefusal);
|
|
250
|
+
|
|
251
|
+
return await withFileMutationQueue(allowedPath, async () => {
|
|
252
|
+
if (context.signal?.aborted) return errorResult(toolCallId, "Operation aborted");
|
|
253
|
+
|
|
254
|
+
// Check the file is readable + writable.
|
|
255
|
+
try {
|
|
256
|
+
await ops.access(allowedPath, { signal: context.signal });
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (context.signal?.aborted) return errorResult(toolCallId, "Operation aborted");
|
|
259
|
+
const err = error as NodeJS.ErrnoException;
|
|
260
|
+
const errorMessage = error instanceof Error && "code" in error ? `Error code: ${err.code}` : String(error);
|
|
261
|
+
return errorResult(toolCallId, `Could not edit file: ${prepared.path}. ${errorMessage}.`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (context.signal?.aborted) return errorResult(toolCallId, "Operation aborted");
|
|
265
|
+
|
|
266
|
+
const { size } = await ops.statFile(allowedPath, { signal: context.signal });
|
|
267
|
+
if (size > maxFileBytes) {
|
|
268
|
+
return errorResult(toolCallId, `Edit target is ${size} bytes, exceeds ${maxFileBytes} byte limit.`);
|
|
269
|
+
}
|
|
270
|
+
if (context.signal?.aborted) return errorResult(toolCallId, "Operation aborted");
|
|
271
|
+
const buffer = await ops.readFile(allowedPath, { maxBytes: maxFileBytes, signal: context.signal });
|
|
272
|
+
if (buffer.length > maxFileBytes) {
|
|
273
|
+
return errorResult(toolCallId, `Edit target is ${buffer.length} bytes, exceeds ${maxFileBytes} byte limit.`);
|
|
274
|
+
}
|
|
275
|
+
if (context.signal?.aborted) return errorResult(toolCallId, "Operation aborted");
|
|
276
|
+
|
|
277
|
+
const rawContent = buffer.toString("utf-8");
|
|
278
|
+
// The model will not include an invisible BOM in oldText.
|
|
279
|
+
const { bom, text: content } = stripBom(rawContent);
|
|
280
|
+
const originalEnding = detectLineEnding(content);
|
|
281
|
+
const normalizedContent = normalizeToLF(content);
|
|
282
|
+
|
|
283
|
+
// Apply exact matching. Throws on no-match / duplicate / overlap / empty / no-change.
|
|
284
|
+
let baseContent: string;
|
|
285
|
+
let newContent: string;
|
|
286
|
+
try {
|
|
287
|
+
({ baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, prepared.path));
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
290
|
+
return errorResult(toolCallId, message);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (context.signal?.aborted) return errorResult(toolCallId, "Operation aborted");
|
|
294
|
+
|
|
295
|
+
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
296
|
+
await ops.writeFile(allowedPath, finalContent, { signal: context.signal });
|
|
297
|
+
options?.onEvent?.({ type: "file_changed", path: allowedPath, op: "edit", toolCallId });
|
|
298
|
+
|
|
299
|
+
const diffResult = generateDiffString(baseContent, newContent);
|
|
300
|
+
const patch = generateUnifiedPatch(prepared.path, baseContent, newContent);
|
|
301
|
+
const preview = generateChangedLinePreview(baseContent, newContent);
|
|
302
|
+
const moreNote = preview.more > 0 ? `\n(… ${preview.more} more changed lines)` : "";
|
|
303
|
+
const confirmation = `Edited ${prepared.path}: ${edits.length} block(s).${preview.text ? `\n${preview.text}` : ""}${moreNote}`;
|
|
304
|
+
return {
|
|
305
|
+
toolCallId,
|
|
306
|
+
name: "edit",
|
|
307
|
+
content: [{ type: "text", text: confirmation }],
|
|
308
|
+
metadata: {
|
|
309
|
+
path: allowedPath,
|
|
310
|
+
diff: diffResult.diff,
|
|
311
|
+
patch,
|
|
312
|
+
firstChangedLine: diffResult.firstChangedLine,
|
|
313
|
+
} satisfies EditToolDetails,
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
} catch (error) {
|
|
317
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
318
|
+
return errorResult(toolCallId, message);
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import type { JsonObject, ToolEffectDeclaration, ToolEffectRecord, ToolResult } from "@arnilo/prism";
|
|
4
|
+
import { HARD_MAX_WRITE_BYTES } from "./limits.js";
|
|
5
|
+
import { resolveContainedMutationPath } from "./mutation-path.js";
|
|
6
|
+
|
|
7
|
+
export const CODING_OBSERVATION_EFFECT = { kind: "none", idempotency: "none" } as const satisfies ToolEffectDeclaration;
|
|
8
|
+
export const CODING_LOCAL_EFFECT = { kind: "local_mutation", idempotency: "optional" } as const satisfies ToolEffectDeclaration;
|
|
9
|
+
export const CODING_UNSUPPORTED_EFFECT = { kind: "external_mutation", idempotency: "unsupported" } as const satisfies ToolEffectDeclaration;
|
|
10
|
+
|
|
11
|
+
export interface CodingEffectReconciliationInput {
|
|
12
|
+
readonly cwd: string;
|
|
13
|
+
readonly record: Pick<ToolEffectRecord, "toolCallId" | "toolName">;
|
|
14
|
+
readonly args: JsonObject;
|
|
15
|
+
readonly signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type CodingEffectReconciliation = { readonly status: "completed"; readonly result: ToolResult } | { readonly status: "unknown" };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Checks only exact local postconditions. Callers retain the pending call arguments,
|
|
22
|
+
* then pass a completed result to ToolEffectStore.resolveUnknown() when this returns one.
|
|
23
|
+
*/
|
|
24
|
+
export async function reconcileCodingToolEffect(input: CodingEffectReconciliationInput): Promise<CodingEffectReconciliation> {
|
|
25
|
+
input.signal?.throwIfAborted();
|
|
26
|
+
const completed = () => ({
|
|
27
|
+
status: "completed" as const,
|
|
28
|
+
result: {
|
|
29
|
+
toolCallId: input.record.toolCallId,
|
|
30
|
+
name: input.record.toolName,
|
|
31
|
+
content: [{ type: "text" as const, text: "Local tool postcondition verified." }],
|
|
32
|
+
value: { reconciled: true },
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
if (input.record.toolName === "write") {
|
|
38
|
+
const path = stringArg(input.args, "path");
|
|
39
|
+
const content = stringArg(input.args, "content");
|
|
40
|
+
if (path === undefined || content === undefined || Buffer.byteLength(content, "utf8") > HARD_MAX_WRITE_BYTES)
|
|
41
|
+
return { status: "unknown" };
|
|
42
|
+
const target = await resolveContainedMutationPath(input.cwd, path);
|
|
43
|
+
const expected = Buffer.from(content, "utf8");
|
|
44
|
+
const handle = await open(target, "r");
|
|
45
|
+
try {
|
|
46
|
+
const st = await handle.stat();
|
|
47
|
+
if (!st.isFile() || st.size !== expected.length) return { status: "unknown" };
|
|
48
|
+
// Verify descriptor still refers to the same inode we resolved (detect swap/symlink).
|
|
49
|
+
try {
|
|
50
|
+
const cur = await lstat(target);
|
|
51
|
+
if (cur.isSymbolicLink() || cur.ino !== st.ino || cur.dev !== st.dev) return { status: "unknown" };
|
|
52
|
+
} catch {
|
|
53
|
+
return { status: "unknown" };
|
|
54
|
+
}
|
|
55
|
+
const data = await handle.readFile();
|
|
56
|
+
return data.equals(expected) ? completed() : { status: "unknown" };
|
|
57
|
+
} finally {
|
|
58
|
+
await handle.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (input.record.toolName === "delete") {
|
|
62
|
+
const path = stringArg(input.args, "path");
|
|
63
|
+
if (path === undefined) return { status: "unknown" };
|
|
64
|
+
await resolveContainedMutationPath(input.cwd, path, { allowMissing: true });
|
|
65
|
+
return (await missing(input.cwd, path)) ? completed() : { status: "unknown" };
|
|
66
|
+
}
|
|
67
|
+
if (input.record.toolName === "move") {
|
|
68
|
+
const from = stringArg(input.args, "from");
|
|
69
|
+
const to = stringArg(input.args, "to");
|
|
70
|
+
if (from === undefined || to === undefined) return { status: "unknown" };
|
|
71
|
+
await Promise.all([
|
|
72
|
+
resolveContainedMutationPath(input.cwd, from, { allowMissing: true }),
|
|
73
|
+
resolveContainedMutationPath(input.cwd, to, { allowMissing: true }),
|
|
74
|
+
]);
|
|
75
|
+
if (await missing(input.cwd, from)) {
|
|
76
|
+
try {
|
|
77
|
+
await resolveContainedMutationPath(input.cwd, to);
|
|
78
|
+
return completed();
|
|
79
|
+
} catch {
|
|
80
|
+
return { status: "unknown" };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Containment/read failure is not proof that an effect did not happen.
|
|
86
|
+
}
|
|
87
|
+
return { status: "unknown" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stringArg(args: JsonObject, name: string): string | undefined {
|
|
91
|
+
const value = args[name];
|
|
92
|
+
return typeof value === "string" ? value : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function missing(cwd: string, path: string): Promise<boolean> {
|
|
96
|
+
try {
|
|
97
|
+
await resolveContainedMutationPath(cwd, path);
|
|
98
|
+
return false;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child environment allow-list builder (P1 hardening).
|
|
3
|
+
*
|
|
4
|
+
* Fail-closed: nothing from `process.env` reaches a child process unless its
|
|
5
|
+
* name is explicitly listed in `inherit`. Callers that need host values
|
|
6
|
+
* (PATH, locale) enumerate them here; provider-approved extras come through
|
|
7
|
+
* `set`. Never pass `process.env` through to a spawn.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Default vars safe to inherit for agent/process/LSP/PTY children. */
|
|
11
|
+
export const DEFAULT_CHILD_ENV_INHERIT: readonly string[] = ["PATH", "LANG", "LC_ALL", "HOME", "TERM"];
|
|
12
|
+
|
|
13
|
+
export interface ChildEnvOptions {
|
|
14
|
+
/** Names copied from `process.env` (absent values are skipped). */
|
|
15
|
+
readonly inherit?: readonly string[];
|
|
16
|
+
/** Explicit values added verbatim (overrides an inherited name). */
|
|
17
|
+
readonly set?: Readonly<Record<string, string>>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildChildEnv({ inherit = [], set }: ChildEnvOptions = {}): Record<string, string> {
|
|
21
|
+
const env: Record<string, string> = {};
|
|
22
|
+
for (const name of inherit) {
|
|
23
|
+
const value = process.env[name];
|
|
24
|
+
if (value !== undefined) env[name] = value;
|
|
25
|
+
}
|
|
26
|
+
if (set) Object.assign(env, set);
|
|
27
|
+
return env;
|
|
28
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-local execution policy (plan 155 Task 6).
|
|
3
|
+
*
|
|
4
|
+
* The kernel `ExecutionPolicy` export is retired: tool policy is a host `tool.call` hook, decided
|
|
5
|
+
* from declared `effects` and roots. These types stay package-internal because the coding tools
|
|
6
|
+
* accept an optional host-supplied policy for their sandbox and command-rule checks (plan 154
|
|
7
|
+
* Task 1 (g)); they are no longer part of `@arnilo/prism`.
|
|
8
|
+
*/
|
|
9
|
+
import type { ToolResult } from "@arnilo/prism";
|
|
10
|
+
|
|
11
|
+
export type ExecutionRisk = "low" | "medium" | "high";
|
|
12
|
+
|
|
13
|
+
export interface ExecutionAction {
|
|
14
|
+
readonly kind: "shell" | "read" | "write" | "edit" | string;
|
|
15
|
+
readonly operation: string;
|
|
16
|
+
readonly paths?: readonly string[];
|
|
17
|
+
readonly command?: string;
|
|
18
|
+
readonly risk?: ExecutionRisk;
|
|
19
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ExecutionDecision {
|
|
23
|
+
readonly allowed: boolean;
|
|
24
|
+
readonly reason?: string;
|
|
25
|
+
readonly modified?: Partial<ExecutionAction>;
|
|
26
|
+
/** When true, tool definitions should expose `exclusive: true` for pre-dispatch serialization. */
|
|
27
|
+
readonly exclusive?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ExecutionPolicy {
|
|
31
|
+
check(action: ExecutionAction): ExecutionDecision | Promise<ExecutionDecision>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ExecutionDeniedError extends Error {
|
|
35
|
+
readonly code = "ERR_PRISM_EXECUTION_DENIED";
|
|
36
|
+
constructor(
|
|
37
|
+
readonly action: ExecutionAction,
|
|
38
|
+
readonly decision: ExecutionDecision,
|
|
39
|
+
) {
|
|
40
|
+
super(decision.reason ?? `Execution denied for ${action.kind}:${action.operation}`);
|
|
41
|
+
this.name = "ExecutionDeniedError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function checkExecution(policy: ExecutionPolicy | undefined, action: ExecutionAction): Promise<ExecutionDecision> {
|
|
46
|
+
return policy ? await policy.check(action) : { allowed: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function applyExecutionDecision(action: ExecutionAction, decision: ExecutionDecision): ExecutionAction {
|
|
50
|
+
return decision.modified ? { ...action, ...decision.modified } : action;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function assertExecutionAllowed(policy: ExecutionPolicy | undefined, action: ExecutionAction): Promise<ExecutionAction> {
|
|
54
|
+
const decision = await checkExecution(policy, action);
|
|
55
|
+
if (!decision.allowed) throw new ExecutionDeniedError(action, decision);
|
|
56
|
+
return applyExecutionDecision(action, decision);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function enforceExecutionPolicy(
|
|
60
|
+
policy: ExecutionPolicy | undefined,
|
|
61
|
+
action: ExecutionAction,
|
|
62
|
+
toolCallId: string,
|
|
63
|
+
toolName: string,
|
|
64
|
+
/** Called once on policy denial so one site emits lifecycle permission_denied for every tool. */
|
|
65
|
+
onDenied?: (input: { readonly toolCallId: string; readonly toolName: string; readonly reason: string }) => void,
|
|
66
|
+
): Promise<{ allowed: true; action: ExecutionAction } | { allowed: false; result: ToolResult }> {
|
|
67
|
+
if (!policy) return { allowed: true, action };
|
|
68
|
+
try {
|
|
69
|
+
const allowedAction = await assertExecutionAllowed(policy, action);
|
|
70
|
+
return { allowed: true, action: allowedAction };
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const message =
|
|
73
|
+
error instanceof ExecutionDeniedError
|
|
74
|
+
? (error.decision.reason ?? error.message)
|
|
75
|
+
: error instanceof Error
|
|
76
|
+
? error.message
|
|
77
|
+
: String(error);
|
|
78
|
+
onDenied?.({ toolCallId, toolName, reason: message });
|
|
79
|
+
return {
|
|
80
|
+
allowed: false,
|
|
81
|
+
result: {
|
|
82
|
+
toolCallId,
|
|
83
|
+
name: toolName,
|
|
84
|
+
content: [{ type: "text", text: message }],
|
|
85
|
+
error: { message },
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize file mutation operations targeting the same file.
|
|
3
|
+
* Operations for different files still run in parallel.
|
|
4
|
+
*
|
|
5
|
+
* Behavioral port of pi's core/tools/file-mutation-queue for @arnilo/prism-tools/coding.
|
|
6
|
+
* stdlib only (node:fs/promises realpath, node:path resolve).
|
|
7
|
+
*
|
|
8
|
+
* ponytail: global process-wide Map mutex keyed by realpath. Ceiling: all
|
|
9
|
+
* mutation queues share one process Map; if a host runs many concurrent
|
|
10
|
+
* sessions in one process they all share the namespace (intended — same file
|
|
11
|
+
* must serialize regardless of session). Upgrade path: scope the Map per
|
|
12
|
+
* ToolRegistry if isolation between registries is ever needed.
|
|
13
|
+
*/
|
|
14
|
+
import { realpath } from "node:fs/promises";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
const fileMutationQueues = new Map<string, Promise<void>>();
|
|
18
|
+
let registrationQueue: Promise<void> = Promise.resolve();
|
|
19
|
+
|
|
20
|
+
function isMissingPathError(error: unknown): boolean {
|
|
21
|
+
return (
|
|
22
|
+
typeof error === "object" &&
|
|
23
|
+
error !== null &&
|
|
24
|
+
"code" in error &&
|
|
25
|
+
((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR")
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function getMutationQueueKey(filePath: string): Promise<string> {
|
|
30
|
+
const resolvedPath = resolve(filePath);
|
|
31
|
+
try {
|
|
32
|
+
return await realpath(resolvedPath);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (isMissingPathError(error)) {
|
|
35
|
+
return resolvedPath;
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run `fn` exclusively for `filePath`: concurrent calls with the same path
|
|
43
|
+
* (or the same realpath) run one after another; calls for different paths run
|
|
44
|
+
* in parallel. Resolves/rejects with `fn`'s result and always releases the slot.
|
|
45
|
+
*/
|
|
46
|
+
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
|
47
|
+
const registration = registrationQueue.then(async () => {
|
|
48
|
+
const key = await getMutationQueueKey(filePath);
|
|
49
|
+
const currentQueue: Promise<void> = fileMutationQueues.get(key) ?? Promise.resolve();
|
|
50
|
+
let releaseNext!: () => void;
|
|
51
|
+
const nextQueue = new Promise<void>((resolveQueue) => {
|
|
52
|
+
releaseNext = resolveQueue;
|
|
53
|
+
});
|
|
54
|
+
const chainedQueue: Promise<void> = currentQueue.then(() => nextQueue);
|
|
55
|
+
fileMutationQueues.set(key, chainedQueue);
|
|
56
|
+
return { key, currentQueue, chainedQueue, releaseNext };
|
|
57
|
+
});
|
|
58
|
+
registrationQueue = registration.then(
|
|
59
|
+
() => undefined,
|
|
60
|
+
() => undefined,
|
|
61
|
+
);
|
|
62
|
+
const { key, currentQueue, chainedQueue, releaseNext } = await registration;
|
|
63
|
+
await currentQueue;
|
|
64
|
+
try {
|
|
65
|
+
return await fn();
|
|
66
|
+
} finally {
|
|
67
|
+
releaseNext();
|
|
68
|
+
if (fileMutationQueues.get(key) === chainedQueue) {
|
|
69
|
+
fileMutationQueues.delete(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|