UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

170 lines (169 loc) • 13.7 kB
import type { ExecutionEnv } from "../internal/harness-types.js"; import type { ToolPolicy } from "./tool-policy.js"; /** fs hand tools that take a `path` and write — the reliably-guardable surface. NotebookEdit joined * in [886]①a (mirror of the fs-write-gate's covered set): its target is `notebook_path`, and a * guard that ignored notebook writes to the same paths by default was the same hole one tool away. */ /** * The agent-configuration block of {@link RECOMMENDED_SENSITIVE_PATTERNS} MIRRORS the read tier (spelled as * literals so the closed set stays statically readable; a set-equality pin keeps the mirror exact): the read tier's rows already encode this severity's predicate ("an agent's own config * FILE, whose `env`/`mcpServers` block is a credential or a command line the host launches") and argue * each one. Shared is the VOCABULARY, not the judgment. * * @contract sensitive_paths.agent_config_tier_is_shared — the write deny set's agent-configuration * block IS the read face's `agent-config` tier, row for row: one table, two judges, and the two faces * cannot drift into disagreeing about which files are an agent's own configuration. The block carries each * row's RUN: the read rows are home-anchored (a project's same-named file reads), while this write set judges * each run anywhere, so a project's own configuration file stays a refused write here. The CONFIGURATION * DIRECTORIES (`.claude`, `.sema`) are on neither face's deny side — their write guard is ASK * severity, where an approver or a persisted allow rule can clear it. Their `hooks/` and `agents/` * sub-directories are the two exceptions and they are NOT part of this mirror: a hook script is an executable * body and an agent definition re-authorizes the agent on load, both guarded on the write face only, by * hand-kept rows of the list below. */ export declare function agentConfigDenyPatterns(): readonly string[]; /** * RECOMMENDED default pattern set for {@link createSensitivePathPolicy} (additive export). * NO upstream verbatim anchor exists (cc-anchors/2.1.209 carries no sensitive/dangerous file-pattern * material), so this is an ENGINEERING-CONSENSUS set — each line states its tradeoff. The factory's * default behavior is unchanged (no implicit default list; wiring this set is the caller's opt-in: * `createSensitivePathPolicy({ patterns: RECOMMENDED_SENSITIVE_PATTERNS, … })`). * * Pattern semantics (must match `compilePatterns` below and the rows' reach, `segmentGuardRow` — segment globs, NOT full-path * globs): each pattern is a `/`-separated run of path SEGMENTS; `*` matches any run of non-slash chars * within one segment and is the ONLY metacharacter (everything else, `?` included, is literal); the run * may match ANYWHERE in the canonical path (so `.ssh` covers `~/.ssh/…` and any other `.ssh` directory — * home-anchoring like `~/` is NOT part of the syntax: a pattern opening with it is refused at wiring). A pattern that reduces to zero * segments is a wiring ERROR (it would guard nothing) and throws rather than being dropped. * Remember this policy guards WRITES only; reads are out of scope by design. * * HRD-PRM-10 — sema's own data root (`$AGENT_DATA_DIR ?? ~/.ai-agent`) is excluded BY CONSTRUCTION, * not by the absence of an entry. The exclusion used to be "just don't add a pattern that names it", * which held only as long as no entry happened to match one of its ANCESTOR segments — and that * assumption died the first time a directory-wide config-root entry was on this list: the shell sets * `AGENT_DATA_DIR = <configRoot>/engine-data` with a configRoot of `~/.sema`, so every path under the * data root carries a `.sema` segment, and the entry swallowed the whole data root with it — * including the memory library the model is INSTRUCTED to write with the Write tool * (`MEMORY_INSTRUCTION_TEMPLATE`), i.e. every memory write refused, on a default server wiring. * That entry is gone (see the severity layering below), but the exclusion stays mechanical — a * deployment may site its data root beneath ANY guarded directory, and both tiers honour it. A * target INSIDE the data root is matched on its path RELATIVE to * that root (the engine's one exempt root, `ToolCallRequest.exemptRoots`), so the root's own ancestry is not the * target's business, while a genuinely sensitive path INSIDE it (`<dataRoot>/x/.ssh/id_rsa`) still * matches. The `sessions/` subtree keeps its own owner: `createTranscriptIntegrityPolicy` is wired * ALWAYS-ON by the runner and answers `ask` — the design's deliberate verdict, since «reading * transcripts is routine» and a legitimate operator write must remain clearable by judgment — and a * deny here would outrank that ask under the deny > ask > allow fold and silently convert it into a * hard refusal for every deployment that wires this list. * * SEVERITY LAYERING for the agent-configuration class: the law is {@link agentConfigDenyPatterns} — * configuration FILES here, the DIRECTORY one step down on the always-on write-protection table * (`WRITE_PROTECTED_DEFAULT_TABLE`'s `.claude` / `.sema` rows — the same reach, since that judge sees the * real target too, {@link import("./write-protect.js").WriteProtectionMatcher.judge}); a directory also * holds the person's own material, and a directory-wide DENY entry turned an explicit instruction to edit * `.claude/projects/…` into a refusal nobody could lift (a deny outranks an ask under the fold). */ export declare const RECOMMENDED_SENSITIVE_PATTERNS: readonly string[]; /** * HRD-PRM-10 — the engine data root, read from the same knob its two consumers read * (`$AGENT_DATA_DIR ?? ~/.ai-agent`, off the HOST at wiring time, NFC-normalized) and returned in * EVERY spelling those consumers resolve it to. There are two, and they disagree for exactly two * inputs (a leading `~`, and a relative value) — the transcript gate expands the tilde and leaves a * relative value to the environment, the memory engine does neither and resolves against * `process.cwd()`. Accepting only one caliber would exempt a directory nobody uses while the other * consumer's real directory stayed guarded, which is the refusal this whole passthrough exists to * remove. Every returned spelling is ANCHORED (see the loop) — that is not a detail. * * Exported as an ENGINE-INTERNAL seam (deliberately not re-exported from `src/index.ts`): the ask-side * write-protection table carries the same passthrough law for the same reason, and a second reading of * "where is the data root" is exactly how the two guards would come to disagree about which directory * the engine owns. One resolver, two judges. */ export declare function dataRootSpellings(explicit: string | undefined): string[]; /** * The data root's identity in the SAME namespace a judged key lives in: every declared spelling * ({@link dataRootSpellings}) run through the SAME `canonicalizeTarget` that mints the target key. Not a * second canonicalization — the same one, on the other operand, which is what lets * {@link relativeUnderRoot}'s comparison be exact. * * Resolved PER CALL, deliberately not cached. A cached canonical root is a snapshot of the symlink * topology at first use: with a data root reached through a symlink, retargeting it left the policy * still exempting the OLD target for the life of the run. The module header already accepts paying * `canonicalizeTarget` twice for a guarded write as the price of a decoupled gate; this is the same * bargain on the other operand, and a request-scoped cache is the same note's answer if profiling * ever asks for one. A spelling that cannot be resolved contributes NO exemption (the guard's side wins). * * ONE resolver for both write judges (the deny set here, the always-on table's target view) — an * engine-internal seam, like the two neighbours above. */ export declare function canonicalDataRootKeys(env: ExecutionEnv, spellings: readonly string[], signal?: AbortSignal, onLookupFailed?: (err: unknown) => void): Promise<string[]>; /** * Is `targetKey` inside the data root `rootKey`, and if so what is its path relative to it? Returns * the relative sub-path (`""` when the target IS the root), or `null` for everything else. * * 🔴 EXACT, and deliberately nothing else. Both operands are minted by the SAME canonicalizer, so * every transformation this comparison could apply — a case fold, an NFC pass, splitting a foreign * separator family, unioning several readings of the root — can only ADD matches. On the DENY side of * this file that is the safe direction and several of those transforms are applied on purpose (a * spelling filter is not a path filter). Here the direction inverts: this test grants an EXEMPTION, so * an added match is not a wider guard, it is a bypass of one — each of case folding, an NFC pass and a * foreign-family read was measured leaking an unrelated sibling into the exemption (their pins name the * shapes). The rule: compare what the canonicalizer produced, byte for byte, every uncertainty falling * to the deny side. * * The separator family comes from the ROOT key (a backslash is an ordinary filename character on * POSIX, so a root `/x/.sema/data` must not swallow the sibling FILE `/x/.sema/data\evil`), and a root * that reduces to nothing exempts nothing rather than exempting the filesystem. * * Exported as an ENGINE-INTERNAL seam (not re-exported from `src/index.ts`), like {@link dataRootSpellings}: * the always-on write-protection judge applies the same exemption on its target view, and a second * containment test would be a second place for the two judges to disagree about what "inside" means. */ export declare function relativeUnderRoot(targetKey: string, rootKey: string): string | null; /** * Build a composable policy that DENIES writes (via the guarded fs tools) whose target lands on a guarded pattern — by any * view its reading holds: the spelling anchored at the tool's declared base, the canonical target, a hop of the leaf's * symlink chain. `patterns` is deployment-supplied — no default list is shipped; empty = a no-op allow. * A deny is a refusal no approver and no persisted allow rule can lift. The ASK severity of the same class * (the agent-configuration DIRECTORIES) is not a tier here: it is the always-on write-protection table's, * which judges the same real target ({@link import("./write-protect.js").WriteProtectionMatcher.judge} — * its contract sentence `write_protection.judges_the_real_target` says the two differ in vocabulary and * severity, never in reach). * * `env` is the same ExecutionEnv the hands run against (inject the one you pass to the Runner), so * canonicalization sees the task's real filesystem (a remote/E2B task resolves in its container). * `rootPath` plays the same fallback-base role it plays on the sibling write gate (see * {@link import("./fs-write-gate-policy.js").FsWriteGatePolicyOptions.rootPath}); the call's live * `ToolCallRequest.cwd` outranks it for the target. * * HRD-PRM-10 — the data-root exemption's root is not this list's: a target inside the engine data root is judged on its path * RELATIVE to that root, and the root is the one the engine resolved for the leg (`ToolCallRequest.exemptRoots`, from * `RunnerDeps.memoryEngineDir`), the same one the engine's own write judgment reads — so the root's ancestor segments * (`.sema`, when a deployment sites its data root under its config root) cannot make the memory library the model is * instructed to WRITE read as a guarded path, and this list and the engine can never disagree about which directory the * engine owns. Outside a Runner the resolver's own chain stands (`$AGENT_DATA_DIR ?? ~/.ai-agent`). The retired option * `dataRoot` refuses construction by name (`config.deps_retired_key`). * * Fail-open by design: a tool with no string `path` returns `allow` here — this guard adds friction on * top of the other gates, it is not the only line of defense, and it must not turn an unrelated tool * call into a spurious verdict. The verdict it owns is narrow and certain: "a view of the target IS a * guarded path → deny", and its absence twin: "where the target really lands could not be determined → deny" (the one * absence law of every path table, path-guard-rows.ts — a list with rows cannot say such a target is none of them; the * reference refuses the same write at its permission check). An auxiliary view that does not answer (a root's key) exempts * nothing. */ export declare function createSensitivePathPolicy(opts: { env: ExecutionEnv; /** `readonly` accepted so {@link RECOMMENDED_SENSITIVE_PATTERNS} can be passed as-is (widening, non-breaking). */ patterns: readonly string[]; rootPath?: string; /** * The host's configuration root when it keeps the agent's configuration directory somewhere other than the home's * `.sema` — the same absolute directory it hands the Runner (`RunnerDeps.agentConfigDir`). Every pattern that opens with * the `.sema` segment (`.sema/settings.json`, `.sema/hooks`, …) then also guards the same tail below this root; the * patterns themselves keep guarding every `.sema` anywhere. Absent ⇒ the list is judged as it stands. A value that is not * the absolute path of an existing directory throws (`config.agent_config_dir_invalid`). */ agentConfigDir?: string; /** Override the guarded tool set (default: `Write`, `Edit`, `MultiEdit`-legacy → Edit, `NotebookEdit`). */ tools?: string[]; }): ToolPolicy;