UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

223 lines (222 loc) • 17.1 kB
/** * design/127 — **参数级权限规则 DSL**(CC 2.1.201 追平批 γ)。声明式规则字符串 → {@link ToolPolicy} * 编译器:`"Tool"`(裸)/ `"Tool(param:pattern)"`(参数级,`*` 通配全串锚定)。CC ground truth * 行号级:解析 `Lh` :55214 · 通配 `ZMr` :55196 · 参数级 matcher `gnn` :595123 · 评估序 `G1e` :595272。 * * 架构边界(design/127 §0,重判过的「core 不长 rule interpreter」):这是纯函数式、opt-in、零 * run-loop 介入的「声明式数据→ToolPolicy」工厂(`createCoarseCommandNamePolicy` 同形态先例)。 * core 不读设置文件、不管 source 层级合并 —— Settings-loader 把各层合并成**单一规则表**后编译 * **一个** policy。 * * ## Deny and ask only * A policy fold says `deny` / `ask`. An allow rule is refused (`config.invalid_permission_rules`, one `unsupported.allow` * issue per rule): "this does not need asking" lives on the allow layer (`TaskSpec.allowedTools`, `RunnerDeps.allowedTools`). * * ## The CONTENT-FORM command lane (the shell tool's own grammar) * * A `Bash(<command>)` / `Bash(<prefix>:*)` / `Bash(<body> *)` rule is not a `param:pattern` rule — its * content is a COMMAND SPELLING in the persisted-rule model's grammar (`permission-rule-model.ts`), and * this compiler now compiles it AS SUCH for the two tightening behaviors: the text goes through the ONE * validator (`parseRuleText(text, behavior)` — the tightening floor, so `Bash(node:*)` as a deny is a * legitimately wide tightening) and the call is judged with the ONE tightening reach * (`programRunReachOf`: the whole command line or any program run in a connector chain — upstream's * per-subcommand deny, the same instrument the org partition and the personal store's deny/ask rows use; * a call the lexer cannot read against a rule is a fail-closed ask, never silence). * It sits in the field-level tier of the evaluation order — right after the bare deny (resp. ask) — which * is upstream's placement (`bs()` whole-tool deny → `AN(…,"deny")` field-level deny → `ph()` whole-tool * ask → `AN(…,"ask")` field-level ask). * * The PATH lane is the same shape for every tool the catalog declares a path target for: a * `Edit(~/.sema/settings.json)` / `Write(src/**)` / `Read(//etc/**)` deny or ask compiles through the same * validator (the model's path grammar — the `//abs-dir/**` directory form and upstream's pattern form: * `//` absolute, `~/` home, `/` project root, otherwise working-directory relative; `*` within a segment, * `**` across) and is judged with `pathRuleReaches` on the call's PROTECTIVE path target (design/388: the * object face's declaration, the catalog's for the name as the floor). The bases a pattern resolves * against are the deployment's (`pathBases`); a pattern whose base the policy was not given is refused at * compile time (`unsupported.path_base`) rather than left to reach nothing at run time — a deny that * silently matches nothing is the failure form this module's header calls the most dangerous one. * A `Read(...)` / `Edit(...)` deny or ask reaches the calls on its behavior's FACES (`PATH_RULE_FACES`), not only * its own tool: a `Read` ask asks for a Grep / Glob rooted in its path, judged on that call's own target. * * 🔴 与 CC 的有意差(design/127 r2): * - CC 参数级 generic 道无 allow lane(gnn 只有 deny/ask)—— 我们同样,且整个 policy 无 allow 臂。 * - CC 对尾缀垃圾静默降级为裸名(Lh `n !== len-1`)—— `parsePermissionRule` 保持 CC parity,但 * **工厂 validator strict**:降级即 invalid(fail-fast 世界观,写错的规则不能静默变宽)。 * - 已知主字段参数规则(如 `Bash(command:…)`)默认 **reject**:通配是 trim 后**整串 lexical** * 匹配,不是 shell AST / 前缀 lane —— `Bash(command:git push*)` 匹配不了 `cd /x && git push`, * 「以为挡住了」比没挡更危险。`primaryFieldGeneric:"allow-lexical"` 显式解锁。 */ import type { NamedToolPolicy } from "./tool-policy.js"; import { parsePermissionRule, type ParsedPermissionRule } from "./permission-rule-syntax.js"; import { type ParsedRule, type PathRuleBases } from "./permission-rule-model.js"; import type { DenyFace, PathRuleDenyRow } from "../tools/fs/read-deny.js"; export { parsePermissionRule, type ParsedPermissionRule }; /** [K-PLATFORM-SWEEP] MINOR#11: Bash's REAL non-primary arg names, accepted as generic param rules * (`Bash(timeout:...)`). Exported SOLELY so a lockstep test can assert it against the live Bash tool * schema — a schema param added without updating this set would compile to silently-never-matching * rules (the exact drift class the Opus 复审 m note warned about). */ export declare const BASH_GENERIC_PARAMS: ReadonlySet<string>; export interface PermissionRule { /** CC 语法:`"Tool"` 或 `"Tool(param:pattern)"`。`Tool()`/`Tool(*)` 归一为裸规则;括号可 `\(`/`\)` 转义。 */ rule: string; behavior: "deny" | "ask"; /** Provenance:进 deny/ask 的 message 供审计(e.g. `"policySettings"`)。层级 * 优先级/tighten-only 语义在 Settings-loader,不在这里。 */ source?: string; } export interface PermissionRuleIssue { rule: string; /** `invalid.*` = 语法/语义错;`unsupported.*` = CC 合法但 v1 不支持的 lane(独立 * 分类,绝不静默 no-op —— `Bash(npm run:*)` 被当 generic 参数规则接受后永不匹配是最危险的失败形态)。 */ code: "invalid.empty_tool" | "invalid.paren" | "invalid.param_split" | "invalid.primary_field" | "invalid.cap_exceeded" /** An ALLOW rule (any spelling): a policy fold says deny / ask only — "this does not need asking" is the allow * layer's (`TaskSpec.allowedTools`, `RunnerDeps.allowedTools`). One issue per allow rule. */ | "unsupported.allow" /** A `behavior` outside the closed set `deny | ask` (a JS caller's typo, an undefined member, a boxed string): refused — * a rule that matched nothing because its verb was misspelled would be a deny that silently never fires. */ | "invalid.behavior" /** A content-form Bash deny/ask rule the ONE rule validator refuses (a redirection, a substitution, a * mid-pattern star, control characters…) — the message carries the validator's own code and sentence. */ | "invalid.command_rule" /** An `allowedTools` value (the request's `TaskSpec.allowedTools`, the deployment's `RunnerDeps.allowedTools`) that is * not a list of strings: one issue for a non-list, one per element that is not a string (`rule` is empty — there is * no rule text to name). */ | "invalid.entry_type" /** design/388 B15 — the closed rule-lint set over a tool's RULE FACE (derived scalar vocabulary; a * declaration only narrows it): the named parameter is not one the tool carries; it is carried but * is an object/array (a rule on it can never match); the tool is unknown to the resolver a leg * supplied (absent resolver ⇒ the catalog alone judges and an unknown tool stays permissive). */ | "unsupported.param_unknown" | "unsupported.param_non_scalar" | "unsupported.tool_unknown" /** A path-form deny/ask whose spelling needs a base this policy was not given (`/x` needs * `pathBases.root`; a relative pattern needs `pathBases.cwd` or `root`) — refused rather than compiled * into a rule that reaches nothing. */ | "unsupported.path_base" | "unsupported.mcp_paren" /** A parenthesised rule whose NAME is a covering spelling in a non-MCP protocol namespace * (`a2a__<peer>__*(x:y)`, `a2a__<peer>(x:y)`). The param DSL is keyed by EXACT tool name, and a * covering spelling is not one — no mounted tool can ever bear it, so the rule compiled clean and * matched nothing. Additive member; the MCP spelling keeps its own {@link * PermissionRuleIssue.code} `unsupported.mcp_paren` (that refusal is wider — it covers exact MCP * names too, for CC parity — and fires first, so no MCP rule's reported code changes). */ | "unsupported.covering_paren" /** HRD-PRM-7: a param-level rule naming a parameter the target tool does not carry. Additive member — * the lane that used to compile silently and never match (see {@link FILE_TOOL_PARAMS}). */ | "unsupported.unknown_param"; message: string; } export interface PermissionRuleCaps { maxRules: number; maxRuleLength: number; maxPatternLength: number; maxStars: number; /** Scalar arg values longer than this are NOT matched textually — the rule **fail-closes** to its * lane (a deny rule targeting an oversized value denies; an ask rule asks). 租户规则 × 模型巨串 * 的热路径成本上界。 */ maxScalarValueChars: number; } export interface PermissionRulePolicyOptions { /** Invalid/unsupported rules: `"throw"`(default — 部署作者写错要立刻知道)aggregates ALL issues * into one error; `"skip"` drops them (pair with {@link validatePermissionRules} for a report, and/or * supply {@link onIssue}). Exception(实现审 M3):`invalid.cap_exceeded` on the WHOLE rule set * (maxRules) throws EVEN under `"skip"` — skipping it would compile an EMPTY table and fail-open. */ onInvalidRule?: "throw" | "skip"; /** Called once per dropped rule under `"skip"` (audit surface — silent drops are how a deployment * discovers, months later, that its deny never existed). Default: one aggregated `console.warn`. */ onIssue?: (issue: PermissionRuleIssue) => void; /** What the policy returns when NO rule matches. Default `"allow"` = 本 policy 无意见(组合语义;不是同意)。 * default-closed 部署用 `"ask"`/`"deny"` —— 必须在这 INSIDE the policy(链尾 ask policy 会改写整条链的答案)。 */ defaultAction?: "allow" | "ask" | "deny"; /** 已知主字段参数规则(`Bash(command:…)` 等)的处置。默认 `"reject"`(整串 lexical ≠ 前缀/AST, * 假安全感);`"allow-lexical"` 显式接受并按 generic 语义匹配。 */ primaryFieldGeneric?: "reject" | "allow-lexical"; caps?: Partial<PermissionRuleCaps>; /** * design/388 B15 — the leg's RULE-FACE resolver (typically `ruleFacesFromRoster(prepared.toolRoster)`): with it * every tool on the leg is judged — MCP and caller tools included — and a rule naming a tool the leg does not * mount is `unsupported.tool_unknown`. Without it the static catalog alone judges; a tool the catalog does not * carry stays permissive (a rule for it compiles unjudged). */ ruleFaces?: (toolName: string) => RuleFaceView | undefined; /** * The bases a path-form deny/ask pattern resolves against at match time ({@link PathRuleBases}): `root` * for `/…` (the task's project root), `cwd` for a relative pattern (the root stands in when absent — the * word's own meaning), `home` for `~/…` (the EXECUTION ENVIRONMENT's home — never the engine process's * own; #644). Facts of the DEPLOYMENT and the task, never of the rule — a pattern is stored as spelled. * A rule whose base is absent (or not an absolute path) here is refused at compile time * (`unsupported.path_base`, naming the base — {@link ruleBasesNeeded} is the same classification). */ pathBases?: PathRuleBases; } /** * Linear-time `*` glob match (design/127 M6: NOT a RegExp — patterns may come from tenant-supplied * Settings layers; the classic two-pointer walk has zero ReDoS surface and the same semantics as CC's * anchored `^…$`+dotAll construction: every non-`*` char is literal, `*` spans anything incl. newlines). */ export declare function wildcardMatch(pattern: string, value: string): boolean; /** * Does a namespaced RULE name cover `toolName`? — the tail branch of CC's rule matcher `VRp` * (:595780), which is UNCONDITIONAL there (its `globMatching` option gates only the whole-name glob * branch, so CC's allow lane — `mVo` :595803, which passes no options — matches MCP segments too). * Three covering forms, matching CC's three disjuncts (`<ns>` = any protocol-table prefix): * · `<ns>__<peer>` — every tool of that peer (rule tool segment absent); * · `<ns>__<peer>__*` — the same set, spelled with the star CC's own settings validator * recommends (`"MCP rules do not support patterns in parentheses … * use mcp__srv__*"`); * · `<ns>__<peer>__get_*` — a glob over the tool segment. * A LITERAL tool segment is deliberately NOT covered here: it is an exact tool name, and the exact * table already answers it — the same split CC makes with its leading `ruleName === toolName`. * * Rule and target must be in the SAME namespace, and peer segments compare EXACTLY (CC * `s.serverName === a.serverName`). A rule must therefore spell the peer the way the mint does; that * is the same string an operator reads off any mounted tool name, and inventing a fuzzier comparison * here would let one rule reach a peer — or a protocol — it does not name. */ export declare function namespacedRuleNameCovers(ruleName: string, toolName: string): boolean; /** True ⇔ this rule name is a namespaced name whose reach is a SET of tools rather than one exact tool * — i.e. the shapes {@link namespacedRuleNameCovers} answers and an exact name table cannot. Every * lane that keys rules by exact tool name needs this to know which entries it must NOT key that way. */ export declare function isNamespacedCoveringRuleName(name: string): boolean; /** * design/388 A-6/7/8/9 (D-3): the per-tool RULE FACE — the parameters a `Tool(param:pattern)` rule may name * (`params`, the SCALAR top-level keys ∪ the path target's aliases), the ones the generic lexical matcher must * not be pointed at (`primaryParams`), the carried-but-non-scalar keys a rule can never match (`nonScalar`), and * whether the tool names a path target at all (the file-tool glob lane). Resolved through * {@link PermissionRulePolicyOptions.ruleFaces} when a leg supplies its roster, else from the static catalog * — the three hand-kept tables (`PRIMARY_FIELDS` / `FILE_TOOLS` / `FILE_TOOL_PARAMS`) this replaced are gone. */ export interface RuleFaceView { primaryParams: readonly string[]; /** The full scalar vocabulary; ABSENT when the resolver does not know it (the static catalog for a * definition whose schema is factory-built) — then only the primary-field check applies. */ params?: readonly string[]; nonScalar?: readonly string[]; pathTargeting?: boolean; } /** The static catalog's answer for a wire name (undefined ⇒ the catalog does not carry it). */ export declare function catalogRuleFaceView(toolName: string): RuleFaceView | undefined; /** Exported for the lockstep guard only (the scalar vocabulary of a catalog tool) — not part of the rule DSL. */ export declare const fileToolParamVocabulary: (canonicalTool: string) => ReadonlySet<string> | undefined; /** * The deny-table row of ONE face-keyed path deny rule, resolved against `bases` — the row's reach IS the rule's (the same * parsed rule, the same bases). Every source of such rows builds them here: a policy's compiled rules, and the person's * persisted store rows. The caller holds the bases the pattern needs (`ruleBasesNeeded`): a row whose base is missing * reads `unreadable`, which this row treats as a reach — a deny row that cannot be judged holds. */ export declare function pathRuleDenyRowOf(rule: { readonly parsed: ParsedRule; readonly ruleText: string; readonly faces: readonly DenyFace[]; }, bases: PathRuleBases): PathRuleDenyRow; /** Dry-run 校验/迁移报告:不 throw,返回全部 issues(含 `unsupported.*` 分类, * 供从 CC settings 迁移的部署逐条对照)。 */ export declare function validatePermissionRules(rules: PermissionRule[], opts?: Pick<PermissionRulePolicyOptions, "primaryFieldGeneric" | "caps" | "ruleFaces" | "pathBases">): PermissionRuleIssue[]; /** * Compile declarative permission rules into ONE {@link ToolPolicy} (design/127). Evaluation order * inside the policy (CC `G1e` :595272 isomorph): bare deny → command deny → param deny → bare ask → * command ask → param ask → bare allow → `defaultAction` (the content-form command tiers are the * field-level tiers of upstream's arbiter, beside the generic param tiers). `check()` is idempotent and side-effect-free (combinePolicies 契约); * all parsing/validation happens HERE, the hot path never re-parses. * * ⚠️ This policy states no allow: a call no rule reaches gets `defaultAction`, whose `"allow"` is "no objection" (states * nothing, clears nothing). Express default-closed via `defaultAction`, never via a trailing always-ask policy. */ export declare function createPermissionRulePolicy(rules: PermissionRule[], opts?: PermissionRulePolicyOptions): NamedToolPolicy;