@sema-agent/core
Version:
Stateless, task-oriented AI agent core
170 lines (169 loc) • 13.7 kB
TypeScript
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;