@sema-agent/core
Version:
Stateless, task-oriented AI agent core
271 lines (270 loc) • 22.7 kB
TypeScript
import type { AgentTool, ExecutionEnv } from "../../internal/harness-types.js";
import { type BeforeWriteHook, type FileEditedHook, type TrackFileEditHook } from "../../core/types.js";
import { type TaskRegistry } from "../../core/task-registry.js";
import { type ReadFileState } from "./safety.js";
import type { PdfModelCapabilities } from "./pdf.js";
export * from "./fs-shared.js";
export * from "./fs-pdf.js";
export * from "./fs-read.js";
export * from "./fs-write.js";
export * from "./fs-search-tools.js";
export * from "./bash-readonly-classifier.js";
export * from "./fs-bash.js";
export * from "./read-deny.js";
export { readDenyHomesOf } from "./read-deny-home.js";
import type { ShellReadAdmission } from "./shell-read-check.js";
export { BASH_CLASSIFY_EXTRA_ALLOW, bashClassifyDefaultAllow } from "./bash-readonly-classifier.js";
import { type ReadDenyEntry } from "./read-deny.js";
import { type ContainmentFace } from "./containment-face.js";
export * from "./containment-face.js";
import { type CwdRef, type ReadImageDownsamplerOption } from "./fs-shared.js";
/** Options for {@link createHandsToolkit}. */
export interface HandsToolkitOptions {
/** CC --add-dir parity: extra allowed containment roots (canonical). Widens file-tool containment
* only; the primary root keeps the cwd/base role. The full `bash` (effect:write) is NOT constrained
* by these — it is deliberately unsandboxed and only uses them for its post-`cd` disclosure note.
* `bash_readonly` IS constrained by them (that face refuses a read outside the roots), so widening
* here widens what the read-only band may read. */
additionalRoots?: readonly string[];
/** The read-boundary whitelist: extra allowed containment roots for the READ faces only —
* read_file/grep/glob/repo_map and `bash_readonly` admit them; edit_file/write_file/notebook_edit
* and the full `bash`'s disclosure roots deliberately do NOT (widening reads must never silently
* widen writes). Read per call, never copied: a caller that grows the array it passed (the Runner's
* in-flight read-root grant) widens the structured read tools for their later calls. Caveat, the same tradeoff `additionalRoots` states:
* `bash_readonly`'s COMMAND allowlist is a deployment declaration — a
* deployment that puts a mutating command into `bashReadonlyAllow` has declared it read-safe,
* and that declaration applies inside every root, these included. Keep the allowlist genuinely
* non-mutating if these roots must stay read-only through the shell face too. */
additionalReadRoots?: readonly string[];
/** Mount a shell tool. Pass false for an env without a real shell (e.g. `StubExecutionEnv`) so a bash
* tool that can only error never reaches the model. Default false. */
includeShell?: boolean;
/** Verifier read-only boundary: mount ONLY effect:read hand tools — read_file/grep/glob
* (+ `bash_readonly` when `includeShell`). Drops edit_file/write_file and the full `bash`. Default false. */
readOnly?: boolean;
/** Override the `bash_readonly` command allowlist (default {@link BASH_READONLY_DEFAULT_ALLOW}). */
bashReadonlyAllow?: readonly string[];
/** The gate's shell read station's pre-spawn ADMISSION (`ShellReadAdmission`, shell-read-check.ts), handed to
* BOTH shell faces this band mounts: what a shell tool runs is what the station judged before approval, and
* the admission re-establishes the same candidates right before the spawn. The station itself — ONE parse of
* the command, its candidates established on the filesystem, judged by the read deny seat and the read
* roots — is the gate's (`ToolGateInput.shellReadCheck`, built by the Runner's hands phase for every shell
* face and every doctrine); this band judges nothing on its own.
*
* ABSENT ⇒ no station judged this mount (a library-direct toolkit with no gate): the full shell runs
* unconfined as it always did, and `bash_readonly` confines NOTHING — its containment IS the station, so a
* consumer mounting the read-only band outside the Runner must run `runToolGate` with a station of its own
* (`createShellReadStation`). */
shellReadAdmission?: ShellReadAdmission;
/** The gate's file read station's ADMISSION (`FileReadStation.admitted`, file-read-check.ts), handed to Read / Grep /
* Glob: an out-of-root target the gate put to a question and then let through is read once, on the canonical key the
* station judged. ABSENT ⇒ no station (a library-direct toolkit): the tools refuse every out-of-root target. */
readAdmission?: (toolCallId: string | undefined, canonicalKey: string) => boolean;
/** Commit-message attribution trailer for the `bash` git protocol. Default NONE — attribution is a
* deployment identity asset, so the branded deployment sets it; pass `"Name <email>"` to advertise
* one, `false` (≡ unset) for no trailer section. */
commitCoAuthor?: string | false;
/** The caller's own {@link CwdRef} to track this task's logical cwd, instead of one
* created internally. The Runner passes its own so it can OBSERVE `cd` moves (`cwdRef.current` changes)
* and emit a `workspace_changed` event. Ignored in `readOnly` mode (no `cd`). Default: a fresh ref at root. */
cwdRef?: CwdRef;
/** Optional process-local registry for unified task_id (`b*`) handles. */
taskRegistry?: TaskRegistry;
/** Runner-owned owner/scope fallback used when a tool execute context is unavailable in tests. */
taskOwner?: string;
taskScope?: string;
/** See createBashTool's taskOpts.sessionId: session-resident background commands. */
sessionId?: string;
/** The task lane of THE retain predicate (`retainsBackgroundProcesses`): the serving run's
* `TaskSpec.retainBackgroundProcesses`. The Runner fills it from the spec of the run it builds this band
* for; a direct mount passes its own declaration. Read by Bash's lifetime sentence (description +
* receipts) and stamped onto every background row Bash registers (stated). Absent ⇒ not stated: the rows
* leave the lane to the owning run's teardown. */
retainBackgroundProcesses?: boolean;
/** Mount TaskOutput/TaskStop directly from this band. Runner sets false and mounts the unified dispatcher once. */
mountBackgroundTaskTools?: boolean;
/** Completion-notification sink for finished background commands (threaded to createBashTool's
* onTaskNotification; the Runner wires the run-local injection lane here). */
taskNotification?: (n: import("../../core/task-notification.js").TaskNotificationPayload, opts?: {
priority?: "now" | "next" | "later";
}) => void;
/** The run-local per-tool-call detach hub (mid-flight ctrl+b → adopt as background). */
detachHub?: import("../../core/tool-detach.js").ToolDetachHub;
/** The Bash `timeout` parameter's DEFAULT budget, in ms — applied when the model omits `timeout`.
* Resolution (options over env over constant — the standing sema discipline: deployments configure
* the tool, not the process): this field ?? env `BASH_DEFAULT_TIMEOUT_MS` ?? 120000; invalid
* (non-finite / <= 0) values are discarded at every leg. See
* {@link import("./fs-shared.js").resolveBashTimeoutCaps} — the SAME resolver renders the tool
* description, so the advertised numbers always match the enforced ones. Absent ⇒ 120s. Applies to
* both shell legs (`bash` and `bash_readonly`). */
bashDefaultTimeoutMs?: number;
/** The Bash `timeout` parameter's engine CEILING, in ms — requests above it are clamped (the
* requested/capped disclosure names THIS value). Resolution: (this field ?? env
* `BASH_MAX_TIMEOUT_MS` ?? 600000), then floored at the resolved default — CC's own only-widen
* semantic: a ceiling below the default is a narrowing intent = invalid, ignored in favor of the
* default. Absent ⇒ 600s. */
bashMaxTimeoutMs?: number;
/** Structured sink for the timeout-cap discard announcements both shell legs emit at mount (see
* {@link import("../../core/types.js").EngineNotice} / `RunnerDeps.onNotice`). Wired, it REPLACES
* their `console.warn` line (a forwarding host must not show every fact twice); absent, the
* console line prints verbatim (last-resort loudness). Threaded per toolkit build — never a
* global. */
onNotice?: (n: import("../../core/types.js").EngineNotice) => void;
/** See createBashTool's taskOpts.oneShot / createTaskOutputTool's TaskToolOptions.oneShot for the
* full contract: this run has no later turn for an async background notification to land in.
* Threaded to both the Bash background-launch receipt and (when this band mounts TaskOutput itself,
* `mountBackgroundTaskTools: true`) the TaskOutput poll receipt. Absent ⇒ the unbranched wording. */
oneShot?: boolean;
/** CC `Zry`/`WZi.#m` parity: see createBashTool's taskOpts field of the same name for the full
* contract. Threaded from {@link HandsBandOptions.autoBackgroundOnTimeout} (a deployment-level
* opt-in) — NOT from `detachHub`/`taskRegistry` merely being present, which are wired unconditionally
* by the Runner for every task and so cannot double as an opt-in signal. Default undefined ⇒ `false`
* downstream. */
autoBackgroundOnTimeout?: boolean;
/** The run's tool-progress sink — see createBashTool's taskOpts field of the same name. Runner-wired
* (a late-bound cell behind the closure), absent for a standalone toolkit build ⇒ the shell's execute
* leg opens no streaming callbacks at all and its `env.exec` options are byte-identical to before. */
onToolProgress?: (body: import("../../core/runner/contracts.js").ToolProgressBody) => void;
/** Is a PERSON's input waiting for this run's next turn boundary — see createBashTool's taskOpts field
* of the same name. Runner-wired (reads the harness's own accepted-but-unserved account); absent ⇒ the
* shell never auto-backgrounds a running command, exactly as before. */
queuedHumanInput?: () => boolean;
/** Read-image downsampler seam ({@link ReadImageDownsamplerOption}). Default undefined = auto-detect
* the optional sharp dependency once per process; `false` disables it (deterministic no-sharp behavior). */
readImageDownsampler?: ReadImageDownsamplerOption;
/** PDF degradation chain: the SERVING model's PDF capability profile (prepare-task derives it via
* {@link import("./fs-pdf.js").pdfModelCapabilitiesOf}). A model without native document input gets pdftotext text extraction →
* rendered page images (vision) → an honest placeholder, instead of a brain-level placeholder. Default
* undefined = fully capable (native document block). */
pdfModelCapabilities?: PdfModelCapabilities;
/** Write-time content gate for Write/Edit/NotebookEdit (see {@link BeforeWriteHook}). The Runner
* wires the MemoryEngine's memory-domain scan here; absent ⇒ byte-identical behavior. */
beforeWrite?: BeforeWriteHook;
/** First-touch file-history hook for Write/Edit/NotebookEdit (see
* {@link TrackFileEditHook}): called after the content gates pass, immediately before the final
* env write, so the file's pre-edit state is durably recorded before it changes. The Runner
* wires it to `FileHistoryStore.trackEdit`; absent ⇒ byte-identical behavior (no history). */
trackFileEdit?: TrackFileEditHook;
/** The mutation lane's LANDED observation seat — fired once per Write/Edit/NotebookEdit call whose
* bytes reached disk (never for a refused/failed one, never for a Bash-authored change). Wired by
* the Runner to the per-run accumulator behind `TaskResult.editedFiles`; absent ⇒ nothing observes.
* Purely additive: it cannot refuse a write, and a fault in it is contained in both shapes (a
* synchronous throw and a rejected promise), neither of them awaited. */
onFileEdited?: FileEditedHook;
/** See createBashTool's taskOpts field of the same name: whether the Monitor tool is on this run's
* roster (the Runner mounts Monitor, this band never does). `false` drops the gh rate-limit hint's
* Monitor clause; absent ⇒ the full wording. */
monitorToolActive?: boolean;
/** ADDITIONS to the built-in sensitive-path READ deny set
* ({@link import("./read-deny.js").READ_FACE_DEFAULT_DENY_ENTRIES}), judged by the structured read
* faces (Read/Grep/Glob/RepoMap and their traversals) in BOTH containment modes. Add-only:
* additions union with the active built-ins, `[]` ≡ absent (union identity), and additions can
* never remove a built-in — the one removal channel is the deployment's built-in configuration
* ({@link readDenyBuiltinTiers} / {@link readDenyBuiltinExclude}). Bad entry shapes throw at wiring
* time. The write faces are untouched (their guard is createSensitivePathPolicy). */
readDenyPatterns?: readonly ReadDenyEntry[];
/** The deployment's built-in-table TIER selection ({@link import("./read-deny.js").ReadDenyBuiltinConfig}
* `tiers` key) — the switch for the built-in table:
* EXACTLY the listed tiers of {@link import("./read-deny.js").READ_FACE_BUILTIN_DENY_TABLE}
* are active (`[]` = none — explicit and legal); absent = the default selection
* ({@link import("./read-deny.js").READ_DENY_DEFAULT_TIERS}: `credentials` and the home-anchored `agent-config`).
* Composes with {@link readDenyBuiltinExclude} (tier selection first, then row removal) and never
* touches the additions seats. Unknown tier names throw at wiring time. Deployment seat ONLY —
* deliberately not a TaskSpec key, so no task can widen below its deployment. */
readDenyBuiltinTiers?: readonly string[];
/** Per-row removal from the built-in table by STABLE NAME (= the row's canonical pattern text, e.g.
* `".ssh"`, `".config/gcloud"`), applied after {@link readDenyBuiltinTiers} selection. Naming a row
* of an inactive tier is a satisfied intent (no-op); a name matching NO table row throws at wiring
* time (never silently ignored). Deployment seat ONLY, same clamp argument as the tiers key. */
readDenyBuiltinExclude?: readonly string[];
/** The spellings of the execution environment's home a home-anchored deny row (`~/…`) is placed under
* ({@link import("./read-deny-home.js").readDenyHomesOf}: the declared home and the account home, each with its canonical
* spelling). Absolute path strings only — any other shape throws at wiring time, naming the seat. The Runner passes the
* spellings its own judge was compiled with. A list given here is the band's home set as is: the account database is
* not asked and no home is added (each listed home is anchored at its spelling and, where the environment answers one,
* its canonical spelling), and the band's home views (a path spelled through a firmlink or a bind mount of a home,
* judged at the home) read this list and the environment's declared home; an entry that is not the declared home is probed once,
* under a short bound of its own, and a probe of it that fails costs that entry alone. The public shell judgment takes the
* same list as {@link import("./shell-read-check.js").ShellReadJudgeInput.readDenyHomes}. */
readDenyHomes?: readonly string[];
/** Where each home-anchored row's literal head leads beyond the home's spellings
* ({@link import("./read-deny-home.js").readDenyHomeLinksOf}, keyed by the row's pattern) — the Runner passes the
* locations its own judge was compiled with, so the band and the gate judge one placement.
* Unless a mount carries both, the band resolves the placement itself the way the Runner does (the homes it carries
* are its home set — the account is not asked and no home is added;
* {@link import("./read-deny-home.js").placeReadDenyAtHome}), and its read tools (Read / Grep / Glob / RepoMap) wait
* for that placement before they judge anything. A placement that fails leaves the home-anchored rows held anywhere.
* Carrying both is the engine's own handoff of ONE placement — its home views travel with it — so
* `readDenyHomes` must then be the very list that placement returned: any other list (a copy) holds the home-anchored
* rows at every location and is named on `console.warn`. */
readDenyHomeLinks?: ReadonlyMap<string, readonly string[]>;
/** The permission-rule rows of the deny table ({@link import("./read-deny.js").PathRuleDenyRow}, `ToolPolicy.pathDenyRules`):
* the read-face rows join the structured read faces' judge beside the segment rows. The Runner passes the leg's own. */
pathDenyRules?: readonly import("./read-deny.js").PathRuleDenyRow[];
/** The READ-face containment state for the structured read faces
* ({@link import("./containment-face.js").ContainmentFace}; resolved through the SAME
* {@link import("./containment-face.js").resolveContainmentFace} order prepare-task uses). Absent ⇒ the
* resolution order's default = "roots" (the engine never opens implicitly). "open" skips ONLY the
* roots containment judgment — the deny set, the UNC out-of-set refusal and the special-file type
* gates run in both faces. Beside `readOnly: true` the mount wins: this seat is deps-shaped (a
* deployment default, not a per-call assertion), so its "open" CLAMPS to "roots" and the clamp is
* ANNOUNCED (`config.read_face_deployment_clamped`, through `onNotice` / `console.warn`) — the loud
* refusal belongs to the TASK seat (`TaskSpec.readFace`), the one genuine per-call contradiction.
* Never affects the write faces — {@link writeFace} is a separate seat with its own default. */
readFace?: ContainmentFace;
/** The WRITE-face containment state for the structured write faces (Edit / Write / NotebookEdit)
* ({@link import("./containment-face.js").ContainmentFace}; resolved through the SAME
* {@link import("./containment-face.js").resolveContainmentFace} order — one body, one default,
* per seat). Absent ⇒ "roots" (today's behavior, byte-identical). "open" skips ONLY the roots
* containment judgment on the write band — the write-side sensitive-path policy, the blocked-device
* gate, the UNC out-of-set refusal and the read-before-edit requirement all still run, and the full
* `bash` face is unaffected either way (it was never fenced). Beside `readOnly: true` the mount
* wins the same way the read seat's does (that mount carries no write faces at all), announced as
* `config.write_face_deployment_clamped`. Never affects the read faces. */
writeFace?: ContainmentFace;
/** A runner's LIVE write face — its resolution, which reads the permission mode's face on every call. Present ⇒
* the write tools read it per call and {@link writeFace} is not consulted (the runner resolved and screened the
* seats itself); absent ⇒ the toolkit's own resolution of {@link writeFace}, fixed at creation. */
writeFaceLive?: () => ContainmentFace;
/** The session's reminder provenance mark: the band's engine-minted
* `<system-reminder>` open tags (the Read dedup/offset/empty reminders and the gh
* rate-limit hint) carry it as `mark="…"`, matching the system-prompt declaration the Runner
* renders for the same session. Threaded by prepare-task; a library-direct mount that omits it
* keeps bare tags — with no declaration in its prompt, a bare tag is the honest form. The tag is
* rendered by the mint home (core/reminder-mint.ts) — the content BODY next to it is
* byte-untouched. */
reminderMark?: string;
/** The per-run trailer/defuse trigger counters (observation seat):
* the Read text/notebook/PDF-text disclosure trailers bump `read.*` / `notebook.*` / `pdf.*`
* keys here. The form suffixes are enumerated once, on the consumer-facing contract
* (`TaskResult.stats.mechanisms.reminderDisclosures` in core/types.ts) — this map is the same
* open record and is not a second, narrower registry of them. Threaded by prepare-task, which folds the
* non-zero result into `TaskResult.stats.mechanisms.reminderDisclosures`; a library-direct
* mount may pass its own object or omit it (counting off — and the whole disclosure pipeline
* is off anyway when `reminderMark` is absent). */
reminderDisclosureCounts?: import("../../core/reminder-disclosure.js").ReminderDisclosureCounts;
}
/** Whether the band's WRITE half (Edit / Write / NotebookEdit, and the full `bash`) mounts for these options — the ONE
* predicate {@link createHandsToolkit} branches on and prepare-task plans by (the memory phase must know, before the
* band exists, whether the instruction-taught write channel will be there). `readOnly` is the verifier boundary. */
export declare function handsWriteHalfMounts(opts: Pick<HandsToolkitOptions, "readOnly">): boolean;
/** The engine's background-output read exemption over one toolkit's seats: the exact registered output file of a
* background command, visible to the caller's own owner / scope / session, on THIS execution env — the file the
* launch receipt tells the model to Read. Shared by the Read tool's fence and the file read station, so the station
* puts no question where the fence would admit. */
export declare function backgroundOutputReadExemption(registry: TaskRegistry | undefined, seat: {
readonly taskOwner?: string;
readonly taskScope?: string;
readonly sessionId?: string;
}, env: ExecutionEnv): ((canonicalKey: string, c?: {
taskId?: string;
principal?: string;
}) => boolean) | undefined;
/**
* Build the per-task hand tool band over an injected env + fresh per-task read state.
* `rootCanonical` is the already-canonicalized containment root (prepare-task resolves it once). Normal
* mode mounts read/edit/write/grep/glob (+`bash` when `includeShell`); `readOnly` mode mounts only the
* effect:read tools read/grep/glob (+`bash_readonly` when `includeShell`) — the verifier boundary. Subset
* selection beyond that is the caller's job via the tool-filter.
*/
export declare function createHandsToolkit(env: ExecutionEnv, readFileState: ReadFileState, rootCanonical: string, opts?: HandsToolkitOptions): AgentTool[];