@sema-agent/core 7.18.0 → 7.19.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 +93 -0
- package/dist/brain/anthropic.js +6 -3
- package/dist/brain/open-responses.js +10 -4
- package/dist/brain/openai.js +5 -2
- package/dist/brain/reasoning.d.ts +13 -13
- package/dist/brain/reasoning.js +2 -2
- package/dist/brain/stream-engine.js +2 -1
- package/dist/brain/stream-shared.d.ts +3 -0
- package/dist/brain/stream-shared.js +1 -0
- package/dist/core/ask-origin.d.ts +16 -0
- package/dist/core/ask-origin.js +8 -0
- package/dist/core/auto-mode-arming.d.ts +6 -0
- package/dist/core/auto-mode-arming.js +5 -5
- package/dist/core/auto-mode-defaults.d.ts +43 -15
- package/dist/core/auto-mode-defaults.js +4 -1
- package/dist/core/auto-mode.d.ts +43 -14
- package/dist/core/auto-mode.js +79 -10
- package/dist/core/checkpoint-store.d.ts +7 -0
- package/dist/core/checkpoint-store.js +2 -2
- package/dist/core/engine-notice.d.ts +30 -0
- package/dist/core/gate-fold.js +5 -1
- package/dist/core/gate-lanes.js +7 -7
- package/dist/core/gate-outcome.d.ts +25 -0
- package/dist/core/gate-outcome.js +7 -0
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +6 -0
- package/dist/core/hooks.d.ts +1 -1
- package/dist/core/read-root-candidate.d.ts +12 -0
- package/dist/core/read-root-candidate.js +24 -0
- package/dist/core/roles.d.ts +9 -1
- package/dist/core/roles.js +4 -0
- package/dist/core/runner/classifier-ladder.d.ts +149 -11
- package/dist/core/runner/classifier-ladder.js +159 -10
- package/dist/core/runner/contracts.d.ts +2 -0
- package/dist/core/runner/denial-limit-arms.js +2 -2
- package/dist/core/runner/prepare-ask-lane.js +1 -0
- package/dist/core/runner/prepare-caps-and-workflow.js +20 -31
- package/dist/core/runner/prepare-config-doors.d.ts +6 -0
- package/dist/core/runner/prepare-config-doors.js +8 -10
- package/dist/core/runner/prepare-project-context.d.ts +5 -1
- package/dist/core/runner/prepare-project-context.js +3 -2
- package/dist/core/runner/prepare-task.js +1 -1
- package/dist/core/runner/resume-apply.js +3 -3
- package/dist/core/runner/run-leg.js +10 -4
- package/dist/core/runner/run-turn-boundary.js +12 -4
- package/dist/core/runner/synthetic-tools.d.ts +67 -29
- package/dist/core/runner/synthetic-tools.js +51 -22
- package/dist/core/runner/turn-attachments.d.ts +43 -12
- package/dist/core/runner/turn-attachments.js +58 -14
- package/dist/core/sensitive-path-policy.d.ts +58 -17
- package/dist/core/sensitive-path-policy.js +33 -5
- package/dist/core/skill-listing-budget.d.ts +112 -0
- package/dist/core/skill-listing-budget.js +58 -0
- package/dist/core/task-spec.d.ts +26 -0
- package/dist/core/tool-policy.d.ts +19 -4
- package/dist/core/tool-policy.js +6 -7
- package/dist/core/tool-spec.d.ts +37 -4
- package/dist/core/types.d.ts +1 -1
- package/dist/core/write-protect.d.ts +8 -3
- package/dist/core/write-protect.js +8 -5
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/tools/fs/bash-readonly-classifier.d.ts +27 -0
- package/dist/tools/fs/bash-readonly-classifier.js +59 -6
- package/dist/tools/fs/fs-bash.js +7 -3
- package/dist/tools/fs/read-deny.d.ts +4 -3
- package/dist/tools/fs/safety.d.ts +15 -0
- package/dist/tools/fs/safety.js +7 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +15 -5
- package/wire-changes/core@7.18.0.json +1273 -0
- package/wire-changes/core@7.19.0.json +457 -0
|
@@ -3,6 +3,22 @@ import type { ToolPolicy } from "./tool-policy.js";
|
|
|
3
3
|
/** fs hand tools that take a `path` and write — the reliably-guardable surface. NotebookEdit joined
|
|
4
4
|
* in [886]①a (mirror of the fs-write-gate's covered set): its target is `notebook_path`, and a
|
|
5
5
|
* guard that ignored notebook writes to the same paths by default was the same hole one tool away. */
|
|
6
|
+
/**
|
|
7
|
+
* The agent-configuration block of {@link RECOMMENDED_SENSITIVE_PATTERNS} MIRRORS the read tier (spelled as
|
|
8
|
+
* 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
|
|
9
|
+
* FILE, whose `env`/`mcpServers` block is a credential or a command line the host launches") and argue
|
|
10
|
+
* each one. Shared is the VOCABULARY, not the judgment.
|
|
11
|
+
*
|
|
12
|
+
* @contract sensitive_paths.agent_config_tier_is_shared — the write deny set's agent-configuration
|
|
13
|
+
* block IS the read face's `agent-config` tier, row for row: one table, two judges, and the two faces
|
|
14
|
+
* cannot drift into disagreeing about which files are an agent's own configuration. The CONFIGURATION
|
|
15
|
+
* DIRECTORIES (`.claude`, `.sema`) are on neither face's deny side — their write guard is ASK
|
|
16
|
+
* severity, where an approver or a persisted allow rule can clear it. Their `hooks/` and `agents/`
|
|
17
|
+
* sub-directories are the two exceptions and they are NOT part of this mirror: a hook script is an executable
|
|
18
|
+
* body and an agent definition re-authorizes the agent on load, both guarded on the write face only, by
|
|
19
|
+
* hand-kept rows of the list below.
|
|
20
|
+
*/
|
|
21
|
+
export declare function agentConfigDenyPatterns(): readonly string[];
|
|
6
22
|
/**
|
|
7
23
|
* RECOMMENDED default pattern set for {@link createSensitivePathPolicy} (additive export).
|
|
8
24
|
* NO upstream verbatim anchor exists (cc-anchors/2.1.209 carries no sensitive/dangerous file-pattern
|
|
@@ -20,13 +36,15 @@ import type { ToolPolicy } from "./tool-policy.js";
|
|
|
20
36
|
*
|
|
21
37
|
* HRD-PRM-10 — sema's own data root (`$AGENT_DATA_DIR ?? ~/.ai-agent`) is excluded BY CONSTRUCTION,
|
|
22
38
|
* not by the absence of an entry. The exclusion used to be "just don't add a pattern that names it",
|
|
23
|
-
* which held only as long as no entry happened to match one of its ANCESTOR segments
|
|
24
|
-
* died the
|
|
39
|
+
* which held only as long as no entry happened to match one of its ANCESTOR segments — and that
|
|
40
|
+
* assumption died the first time a directory-wide config-root entry was on this list: the shell sets
|
|
25
41
|
* `AGENT_DATA_DIR = <configRoot>/engine-data` with a configRoot of `~/.sema`, so every path under the
|
|
26
|
-
* data root carries a `.sema` segment, and
|
|
27
|
-
*
|
|
42
|
+
* data root carries a `.sema` segment, and the entry swallowed the whole data root with it —
|
|
43
|
+
* including the memory library the model is INSTRUCTED to write with the Write tool
|
|
28
44
|
* (`MEMORY_INSTRUCTION_TEMPLATE`), i.e. every memory write refused, on a default server wiring.
|
|
29
|
-
*
|
|
45
|
+
* That entry is gone (see the severity layering below), but the exclusion stays mechanical — a
|
|
46
|
+
* deployment may site its data root beneath ANY guarded directory, and both tiers honour it. A
|
|
47
|
+
* target INSIDE the data root is matched on its path RELATIVE to
|
|
30
48
|
* that root (see {@link createSensitivePathPolicy}'s `dataRoot`), so the root's own ancestry is not the
|
|
31
49
|
* target's business, while a genuinely sensitive path INSIDE it (`<dataRoot>/x/.ssh/id_rsa`) still
|
|
32
50
|
* matches. The `sessions/` subtree keeps its own owner: `createTranscriptIntegrityPolicy` is wired
|
|
@@ -34,8 +52,22 @@ import type { ToolPolicy } from "./tool-policy.js";
|
|
|
34
52
|
* transcripts is routine» and a legitimate operator write must remain clearable by judgment — and a
|
|
35
53
|
* deny here would outrank that ask under the deny > ask > allow fold and silently convert it into a
|
|
36
54
|
* hard refusal for every deployment that wires this list.
|
|
55
|
+
*
|
|
56
|
+
* SEVERITY LAYERING for the agent-configuration class: the law is {@link agentConfigDenyPatterns} —
|
|
57
|
+
* configuration FILES here, the DIRECTORY one step down on {@link RECOMMENDED_SENSITIVE_ASK_PATTERNS}
|
|
58
|
+
* (same judge, so the severity drops and the reach does not); a directory also holds the person's own
|
|
59
|
+
* material, and the directory-wide entry turned an explicit instruction to edit `.claude/projects/…`
|
|
60
|
+
* into a refusal nobody could lift (a deny outranks an ask under the fold).
|
|
37
61
|
*/
|
|
38
62
|
export declare const RECOMMENDED_SENSITIVE_PATTERNS: readonly string[];
|
|
63
|
+
/**
|
|
64
|
+
* The ASK-severity half of the same guard: the agent-configuration DIRECTORIES, on the SAME judge
|
|
65
|
+
* ({@link createSensitivePathPolicy}'s `askPatterns` seat, whose ABSENCE reads as this list) — only the
|
|
66
|
+
* directories, since the configuration FILES inside them are deny material above. It lives HERE and not
|
|
67
|
+
* only on `WRITE_PROTECTED_DEFAULT_TABLE`, which names the same segments but judges LEXICALLY: a
|
|
68
|
+
* symlink with an innocent name walks past that one, so demoting to it alone trades severity for REACH.
|
|
69
|
+
*/
|
|
70
|
+
export declare const RECOMMENDED_SENSITIVE_ASK_PATTERNS: readonly string[];
|
|
39
71
|
/**
|
|
40
72
|
* HRD-PRM-10 — the engine data root, read from the same knob its two consumers read
|
|
41
73
|
* (`$AGENT_DATA_DIR ?? ~/.ai-agent`, off the HOST at wiring time, NFC-normalized) and returned in
|
|
@@ -53,27 +85,36 @@ export declare const RECOMMENDED_SENSITIVE_PATTERNS: readonly string[];
|
|
|
53
85
|
*/
|
|
54
86
|
export declare function dataRootSpellings(explicit: string | undefined): string[];
|
|
55
87
|
/**
|
|
56
|
-
* Build a composable policy that
|
|
57
|
-
*
|
|
58
|
-
* set
|
|
88
|
+
* Build a composable policy that judges writes (via the guarded fs tools) by their CANONICAL target,
|
|
89
|
+
* at TWO severities from ONE judge. `patterns` is the DENY set — deployment-supplied, no default list
|
|
90
|
+
* is shipped; `askPatterns` is the ASK set, judged the same way and consulted only where the deny set
|
|
91
|
+
* misses, so a path on both denies. Both empty = a no-op allow.
|
|
92
|
+
*
|
|
93
|
+
* @contract sensitive_paths.two_severities_one_judge — ONE canonicalizing judge answers both
|
|
94
|
+
* severities: a deny is a refusal no approver and no persisted allow rule can lift, an ask is one
|
|
95
|
+
* explicit look that either of them may clear. Lowering a path's severity here never lowers its REACH
|
|
96
|
+
* — both tiers see the real target (symlinks resolved, relative spellings resolved against the call's
|
|
97
|
+
* live cwd), which the synchronous write-protection TABLE deliberately does not.
|
|
59
98
|
*
|
|
60
99
|
* `env` is the same ExecutionEnv the hands run against (inject the one you pass to the Runner), so
|
|
61
100
|
* canonicalization sees the task's real filesystem (a remote/E2B task resolves in its container).
|
|
62
|
-
* `rootPath`
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* the guard judges the file the tool will actually write. `rootPath` still serves every deployment that
|
|
66
|
-
* calls this policy outside a Runner.
|
|
101
|
+
* `rootPath` plays the same fallback-base role it plays on the sibling write gate (see
|
|
102
|
+
* {@link import("./fs-write-gate-policy.js").FsWriteGatePolicyOptions.rootPath}); the call's live
|
|
103
|
+
* `ToolCallRequest.cwd` outranks it for the target.
|
|
67
104
|
*
|
|
68
|
-
* Fail-open by design: a tool with no string `path
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
105
|
+
* Fail-open by design: a tool with no string `path` returns `allow` here — this guard adds friction on
|
|
106
|
+
* top of the other gates, it is not the only line of defense, and it must not turn an unrelated tool
|
|
107
|
+
* call into a spurious verdict. The verdicts it owns are narrow and certain: "the real target IS a
|
|
108
|
+
* guarded path → deny / ask". (A target it could not RESOLVE is the one fail-CLOSED arm, below.)
|
|
72
109
|
*/
|
|
73
110
|
export declare function createSensitivePathPolicy(opts: {
|
|
74
111
|
env: ExecutionEnv;
|
|
75
112
|
/** `readonly` accepted so {@link RECOMMENDED_SENSITIVE_PATTERNS} can be passed as-is (widening, non-breaking). */
|
|
76
113
|
patterns: readonly string[];
|
|
114
|
+
/** The ASK-severity set, same syntax and same judge as `patterns`, consulted only where the deny set
|
|
115
|
+
* misses. ABSENT ⇒ {@link RECOMMENDED_SENSITIVE_ASK_PATTERNS}, so a deployment already wiring this
|
|
116
|
+
* guard keeps its reach over those directories without a code change; `[]` = no ask tier. */
|
|
117
|
+
askPatterns?: readonly string[];
|
|
77
118
|
rootPath?: string;
|
|
78
119
|
/**
|
|
79
120
|
* HRD-PRM-10 — the engine data root this deployment runs on. Default
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { canonicalizeTarget, expandHomeTilde, isAbsoluteForFamily, isWinFormPath, pathFamilyOf, writeTargetPath } from "../tools/fs/safety.js";
|
|
4
|
-
import { compileSegmentPattern, matchSegmentPatterns } from "../tools/fs/read-deny.js";
|
|
4
|
+
import { compileSegmentPattern, matchSegmentPatterns, READ_FACE_BUILTIN_DENY_TABLE } from "../tools/fs/read-deny.js";
|
|
5
5
|
import { isProtectedWrite, protectivePathTargetOf } from "./tool-registry.js";
|
|
6
6
|
import { pathTargetBaseOf } from "./effective-path-target.js";
|
|
7
|
+
export function agentConfigDenyPatterns() {
|
|
8
|
+
return READ_FACE_BUILTIN_DENY_TABLE.filter((row) => row.tier === "agent-config").map((row) => row.pattern);
|
|
9
|
+
}
|
|
7
10
|
export const RECOMMENDED_SENSITIVE_PATTERNS = [
|
|
8
11
|
".env",
|
|
9
12
|
".env.*",
|
|
@@ -18,10 +21,25 @@ export const RECOMMENDED_SENSITIVE_PATTERNS = [
|
|
|
18
21
|
".git/hooks",
|
|
19
22
|
".git/config",
|
|
20
23
|
".git/modules",
|
|
21
|
-
".
|
|
22
|
-
".
|
|
23
|
-
".sema",
|
|
24
|
+
".sema/settings.json",
|
|
25
|
+
".sema/settings.local.json",
|
|
24
26
|
".sema.*",
|
|
27
|
+
".claude/settings.json",
|
|
28
|
+
".claude/settings.local.json",
|
|
29
|
+
".claude.*",
|
|
30
|
+
".mcp.json",
|
|
31
|
+
".ai-agent/.env",
|
|
32
|
+
".sema/engine-data/.env",
|
|
33
|
+
".codex/config.toml",
|
|
34
|
+
".cursor/mcp.json",
|
|
35
|
+
".continue/config.json",
|
|
36
|
+
".continue/config.yaml",
|
|
37
|
+
".aider.conf.yml",
|
|
38
|
+
".gemini/settings.json",
|
|
39
|
+
".claude/hooks",
|
|
40
|
+
".sema/hooks",
|
|
41
|
+
".claude/agents",
|
|
42
|
+
".sema/agents",
|
|
25
43
|
".sema-notifier-build",
|
|
26
44
|
".aws",
|
|
27
45
|
".config/gcloud",
|
|
@@ -32,6 +50,7 @@ export const RECOMMENDED_SENSITIVE_PATTERNS = [
|
|
|
32
50
|
".bash_history",
|
|
33
51
|
".zsh_history",
|
|
34
52
|
];
|
|
53
|
+
export const RECOMMENDED_SENSITIVE_ASK_PATTERNS = [".claude", ".sema"];
|
|
35
54
|
const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
|
|
36
55
|
export function dataRootSpellings(explicit) {
|
|
37
56
|
const raw = explicit ?? process.env.AGENT_DATA_DIR ?? join(homedir(), ".ai-agent");
|
|
@@ -77,6 +96,7 @@ function matchSensitive(canonicalKey, compiled, aliasResolved) {
|
|
|
77
96
|
}
|
|
78
97
|
export function createSensitivePathPolicy(opts) {
|
|
79
98
|
const compiled = compilePatterns(opts.patterns);
|
|
99
|
+
const compiledAsk = compilePatterns(opts.askPatterns ?? RECOMMENDED_SENSITIVE_ASK_PATTERNS);
|
|
80
100
|
const dataRootPaths = dataRootSpellings(opts.dataRoot);
|
|
81
101
|
const dataRootKeys = async (signal) => {
|
|
82
102
|
const keys = [];
|
|
@@ -94,7 +114,7 @@ export function createSensitivePathPolicy(opts) {
|
|
|
94
114
|
const guardedByName = opts.tools !== undefined ? new Set(opts.tools) : undefined;
|
|
95
115
|
return {
|
|
96
116
|
async check(req, signal) {
|
|
97
|
-
if (compiled.length === 0)
|
|
117
|
+
if (compiled.length === 0 && compiledAsk.length === 0)
|
|
98
118
|
return { action: "allow" };
|
|
99
119
|
if (guardedByName !== undefined ? !guardedByName.has(req.toolName) : !isProtectedWrite(req))
|
|
100
120
|
return { action: "allow" };
|
|
@@ -129,6 +149,14 @@ export function createSensitivePathPolicy(opts) {
|
|
|
129
149
|
decisionReason: "safety",
|
|
130
150
|
};
|
|
131
151
|
}
|
|
152
|
+
const askHit = matchSensitive(judged, compiledAsk, canon.aliasResolved === true);
|
|
153
|
+
if (askHit) {
|
|
154
|
+
return {
|
|
155
|
+
action: "ask",
|
|
156
|
+
message: `this write's real target resolves inside the guarded directory "${askHit}" — explicit approval required`,
|
|
157
|
+
decisionReason: "safety",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
132
160
|
return { action: "allow" };
|
|
133
161
|
},
|
|
134
162
|
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skills-listing BUDGET SEAT as vocabulary: the two knobs a deployment may declare
|
|
3
|
+
* (`TaskSpec.skillListingBudgetFraction` / `TaskSpec.skillListingMaxDescChars`), the ONE reader that
|
|
4
|
+
* lowers them to the numbers the renderer measures against, and the disclosure announced when the
|
|
5
|
+
* budget costs a skill its description. The reader is pure; the announcer's only effect is the notice.
|
|
6
|
+
* `runner/synthetic-tools.ts` owns the renderer that consumes the numbers.
|
|
7
|
+
*
|
|
8
|
+
* Sibling of `tool-search-setting.ts` in shape and in posture: a value outside its domain REFUSES
|
|
9
|
+
* loudly rather than folding back to the default, because a knob that is silently dropped reads to the
|
|
10
|
+
* deployment as a knob that took effect.
|
|
11
|
+
*/
|
|
12
|
+
import { type EngineNotice } from "./engine-notice.js";
|
|
13
|
+
/** Fraction of the model's context window the listing may spend when the deployment declares nothing. */
|
|
14
|
+
export declare const SKILL_LISTING_BUDGET_FRACTION_DEFAULT = 0.01;
|
|
15
|
+
/** Per-skill description cap (code units, ellipsis included) when the deployment declares nothing. */
|
|
16
|
+
export declare const SKILL_LISTING_DESC_MAX_CHARS_DEFAULT = 1536;
|
|
17
|
+
/** Context window assumed for a model that declares none. */
|
|
18
|
+
export declare const SKILL_LISTING_DEFAULT_CONTEXT_TOKENS = 200000;
|
|
19
|
+
/**
|
|
20
|
+
* The token→byte exchange rate this budget allocates at.
|
|
21
|
+
*
|
|
22
|
+
* @contract skills.listing_budget_exchange_rate — deliberately NOT `DEFAULT_CHARS_PER_TOKEN` (3), and
|
|
23
|
+
* the difference is directional, not an inconsistency. That constant serves the compaction/context
|
|
24
|
+
* estimators, which convert bytes→tokens and must OVER-estimate the token count to stay inside a
|
|
25
|
+
* window; a low rate is the conservative choice there. This seat converts tokens→a byte ALLOWANCE,
|
|
26
|
+
* where a low rate silently SHRINKS what the deployment may spend. Two directions, two numbers, both
|
|
27
|
+
* named. At 3 the default allowance would be 6 000 bytes rather than 8 000 — a quarter of every
|
|
28
|
+
* default deployment's listing, removed by an estimator's safety margin.
|
|
29
|
+
*/
|
|
30
|
+
export declare const SKILL_LISTING_BYTES_PER_TOKEN = 4;
|
|
31
|
+
/**
|
|
32
|
+
* The hard ceiling on the rendered `<skills>` block, in UTF-8 bytes.
|
|
33
|
+
*
|
|
34
|
+
* @contract skills.listing_budget_ceiling — this is a STRUCTURAL property of the delivery lane, not a
|
|
35
|
+
* policy choice and not a superset optimization. The block is not part of the system prompt: it is the
|
|
36
|
+
* body of the `skills_listing` turn attachment, and every attachment bundle is capped at
|
|
37
|
+
* `ATTACHMENT_BYTE_CAP` (8 KiB) by the boundary's `capAttachments`, which CLIPS anything over. A
|
|
38
|
+
* clipped skills frame never satisfies the run loop's intact-survival commit predicate, so the
|
|
39
|
+
* announced set never advances and the frame is re-rendered and re-clipped at every subsequent
|
|
40
|
+
* boundary — permanently. The cap is therefore enforced byte-exact over the WHOLE render (fences,
|
|
41
|
+
* headers, markers, joiners included) and MUST stay ≤ `ATTACHMENT_BYTE_CAP`; the renderer's module
|
|
42
|
+
* pins that relation, since importing the attachment module here would invert the dependency.
|
|
43
|
+
*
|
|
44
|
+
* The consequence is a one-way knob: {@link resolveSkillListingBudget} clamps the declared budget to
|
|
45
|
+
* this number, so a raised fraction (or a model with a very large window) can LOWER the listing budget
|
|
46
|
+
* but can never raise it above this ceiling. When the clamp costs a description, both numbers ride the
|
|
47
|
+
* disclosure ({@link announceSkillListingTruncation}), so the deployment reads what it asked for beside
|
|
48
|
+
* what it got instead of inferring the ceiling from a short listing.
|
|
49
|
+
*/
|
|
50
|
+
export declare const SKILLS_BLOCK_MAX_BYTES = 8000;
|
|
51
|
+
/** The typed refusal a budget seat outside its domain raises: a bad value never selects a smaller
|
|
52
|
+
* listing silently, and never folds back to the default budget. */
|
|
53
|
+
export declare const SKILL_LISTING_BUDGET_INVALID_CODE = "config.skill_listing_budget_invalid";
|
|
54
|
+
/** The notice code minted when the budget cost at least one skill its description or its line. */
|
|
55
|
+
export declare const SKILL_LISTING_TRUNCATED_CODE = "skills.listing_truncated";
|
|
56
|
+
/** The lowered seat: the two numbers the renderer measures against, plus what was asked for. */
|
|
57
|
+
export interface ResolvedSkillListingBudget {
|
|
58
|
+
/** The budget the renderer enforces over the whole block, in UTF-8 bytes — already clamped to
|
|
59
|
+
* {@link SKILLS_BLOCK_MAX_BYTES}. */
|
|
60
|
+
blockMaxBytes: number;
|
|
61
|
+
/** What the declared fraction asked for BEFORE the structural clamp. Equal to `blockMaxBytes`
|
|
62
|
+
* whenever the ceiling did not bind; strictly greater when it did. */
|
|
63
|
+
requestedBytes: number;
|
|
64
|
+
/** Per-skill description cap in code units, ellipsis included. */
|
|
65
|
+
descMaxChars: number;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The ONE reader of the two listing-budget seats.
|
|
69
|
+
*
|
|
70
|
+
* `skillListingBudgetFraction` must be a finite number in `(0, 1]` — zero is excluded deliberately: a
|
|
71
|
+
* zero budget is not "spend nothing", it is a listing every skill loses its description to, which the
|
|
72
|
+
* deployment expresses by declaring fewer skills. `skillListingMaxDescChars` must be a positive safe
|
|
73
|
+
* integer. Both absent ⇒ the documented defaults, which reproduce the pre-seat render byte for byte
|
|
74
|
+
* (`200 000 × 4 × 0.01` = 8 000 = {@link SKILLS_BLOCK_MAX_BYTES}).
|
|
75
|
+
*
|
|
76
|
+
* The window is `contextTokens ?? contextWindow`, the same physical coordinate the deferral gate
|
|
77
|
+
* measures against. A window that is absent OR unusable (non-finite, non-positive) reads as
|
|
78
|
+
* {@link SKILL_LISTING_DEFAULT_CONTEXT_TOKENS}: one rule, not a case per bad shape — a model's own
|
|
79
|
+
* declaration is judged by the model doors, and this seat must still produce a number a renderer can
|
|
80
|
+
* compare against.
|
|
81
|
+
*/
|
|
82
|
+
export declare function resolveSkillListingBudget(opts: {
|
|
83
|
+
fraction?: unknown;
|
|
84
|
+
maxDescChars?: unknown;
|
|
85
|
+
model?: {
|
|
86
|
+
contextTokens?: number;
|
|
87
|
+
contextWindow?: number;
|
|
88
|
+
} | undefined;
|
|
89
|
+
}): ResolvedSkillListingBudget;
|
|
90
|
+
/**
|
|
91
|
+
* Announce a listing the budget degraded — delivered only when a skill actually lost its description
|
|
92
|
+
* (`nameOnly`) or its line (`dropped`), so a deployment whose roster fits hears nothing.
|
|
93
|
+
*
|
|
94
|
+
* Called from the two lanes that DELIVER a listing frame (the leg's first-frame producer and the turn
|
|
95
|
+
* boundary), never from the config door: the door cannot see the deployment's
|
|
96
|
+
* `attachments.skillsListing` switch (resolved at run start), it runs per prepared task rather than per
|
|
97
|
+
* delivered listing, and a resume leg reaches only the boundary.
|
|
98
|
+
*
|
|
99
|
+
* Audience is `operator`: the person who can raise the fraction, shorten a description or remove a
|
|
100
|
+
* skill runs the deployment, not the session. `requestedBudgetBytes` rides beside `budgetBytes`
|
|
101
|
+
* precisely so the structural ceiling ({@link SKILLS_BLOCK_MAX_BYTES}) is legible at the moment it
|
|
102
|
+
* costs something — the two differ only when the clamp bound.
|
|
103
|
+
*/
|
|
104
|
+
export declare function announceSkillListingTruncation(onNotice: ((notice: EngineNotice) => void) | undefined, opts: {
|
|
105
|
+
/** The denominator: how many skills the DELIVERED frame set out to list — the whole roster on an
|
|
106
|
+
* initial block, the newly announced set on a drift frame. Read off that frame's own render. */
|
|
107
|
+
total: number;
|
|
108
|
+
nameOnly: readonly string[];
|
|
109
|
+
dropped: readonly string[];
|
|
110
|
+
budget: ResolvedSkillListingBudget;
|
|
111
|
+
sessionId?: string | undefined;
|
|
112
|
+
}): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { deliverEngineNotice } from "./engine-notice.js";
|
|
2
|
+
import { describeRejectedValue } from "./tool-search-setting.js";
|
|
3
|
+
export const SKILL_LISTING_BUDGET_FRACTION_DEFAULT = 0.01;
|
|
4
|
+
export const SKILL_LISTING_DESC_MAX_CHARS_DEFAULT = 1_536;
|
|
5
|
+
export const SKILL_LISTING_DEFAULT_CONTEXT_TOKENS = 200_000;
|
|
6
|
+
export const SKILL_LISTING_BYTES_PER_TOKEN = 4;
|
|
7
|
+
export const SKILLS_BLOCK_MAX_BYTES = 8_000;
|
|
8
|
+
export const SKILL_LISTING_BUDGET_INVALID_CODE = "config.skill_listing_budget_invalid";
|
|
9
|
+
export const SKILL_LISTING_TRUNCATED_CODE = "skills.listing_truncated";
|
|
10
|
+
function budgetRefusal(problem) {
|
|
11
|
+
const e = new Error(`TaskSpec.${problem}; a bad value refuses loudly, nothing falls back to the default listing budget.`);
|
|
12
|
+
e.code = SKILL_LISTING_BUDGET_INVALID_CODE;
|
|
13
|
+
return e;
|
|
14
|
+
}
|
|
15
|
+
export function resolveSkillListingBudget(opts) {
|
|
16
|
+
const { fraction, maxDescChars } = opts;
|
|
17
|
+
if (fraction !== undefined && (typeof fraction !== "number" || !Number.isFinite(fraction) || fraction <= 0 || fraction > 1)) {
|
|
18
|
+
throw budgetRefusal(`skillListingBudgetFraction: invalid value ${describeRejectedValue(fraction)} — expected a finite fraction in (0, 1]`);
|
|
19
|
+
}
|
|
20
|
+
if (maxDescChars !== undefined && (typeof maxDescChars !== "number" || !Number.isSafeInteger(maxDescChars) || maxDescChars <= 0)) {
|
|
21
|
+
throw budgetRefusal(`skillListingMaxDescChars: invalid value ${describeRejectedValue(maxDescChars)} — expected a positive integer`);
|
|
22
|
+
}
|
|
23
|
+
const declaredWindow = opts.model?.contextTokens ?? opts.model?.contextWindow;
|
|
24
|
+
const windowTokens = typeof declaredWindow === "number" && Number.isFinite(declaredWindow) && declaredWindow > 0
|
|
25
|
+
? declaredWindow
|
|
26
|
+
: SKILL_LISTING_DEFAULT_CONTEXT_TOKENS;
|
|
27
|
+
const requestedBytes = Math.max(1, Math.floor(windowTokens * SKILL_LISTING_BYTES_PER_TOKEN * (fraction ?? SKILL_LISTING_BUDGET_FRACTION_DEFAULT)));
|
|
28
|
+
return {
|
|
29
|
+
blockMaxBytes: Math.min(requestedBytes, SKILLS_BLOCK_MAX_BYTES),
|
|
30
|
+
requestedBytes,
|
|
31
|
+
descMaxChars: maxDescChars ?? SKILL_LISTING_DESC_MAX_CHARS_DEFAULT,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function announceSkillListingTruncation(onNotice, opts) {
|
|
35
|
+
const { total, nameOnly, dropped, budget } = opts;
|
|
36
|
+
if (nameOnly.length === 0 && dropped.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
const clamped = budget.requestedBytes > budget.blockMaxBytes;
|
|
39
|
+
deliverEngineNotice(onNotice, {
|
|
40
|
+
code: SKILL_LISTING_TRUNCATED_CODE,
|
|
41
|
+
message: `The skills listing does not fit its ${budget.blockMaxBytes}-byte budget` +
|
|
42
|
+
(clamped ? ` (${budget.requestedBytes} bytes were declared; the listing's delivery lane caps it at ${SKILLS_BLOCK_MAX_BYTES})` : "") +
|
|
43
|
+
`: ${nameOnly.length} of ${total} are listed name-only` +
|
|
44
|
+
(dropped.length > 0 ? `, and ${dropped.length} did not fit the listing at all` : "") +
|
|
45
|
+
`. The \`Skill\` tool still serves every one of them. Raise skillListingBudgetFraction, lower` +
|
|
46
|
+
` skillListingMaxDescChars, or declare fewer skills.`,
|
|
47
|
+
detail: {
|
|
48
|
+
budgetBytes: budget.blockMaxBytes,
|
|
49
|
+
requestedBudgetBytes: budget.requestedBytes,
|
|
50
|
+
descMaxChars: budget.descMaxChars,
|
|
51
|
+
total,
|
|
52
|
+
kept: total - nameOnly.length - dropped.length,
|
|
53
|
+
nameOnly: [...nameOnly],
|
|
54
|
+
dropped: [...dropped],
|
|
55
|
+
...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
package/dist/core/task-spec.d.ts
CHANGED
|
@@ -935,6 +935,32 @@ export interface TaskSpec {
|
|
|
935
935
|
* end-user-authored skills are responsible for vetting them: a skill IS instructions by design.
|
|
936
936
|
*/
|
|
937
937
|
skills?: SkillSpec[];
|
|
938
|
+
/**
|
|
939
|
+
* Fraction of the model's context window the `<skills>` listing may spend. Absent ⇒ `0.01` (1%).
|
|
940
|
+
* Domain `(0, 1]`; anything else — including `0`, a string, `NaN` — REFUSES at the door with
|
|
941
|
+
* `config.skill_listing_budget_invalid` rather than folding back to the default.
|
|
942
|
+
*
|
|
943
|
+
* The budget is `(contextTokens ?? contextWindow ?? 200 000) × 4 × fraction`, in UTF-8 bytes, and the
|
|
944
|
+
* listing degrades inside it: every skill stays listed, the lower-priority ones (declaration order)
|
|
945
|
+
* lose their descriptions first. Raising this is opting in to a higher per-turn context cost.
|
|
946
|
+
*
|
|
947
|
+
* ⚠️ ONE-WAY IN PRACTICE (`@contract skills.listing_budget_ceiling`): the listing rides the turn-
|
|
948
|
+
* attachment lane, whose bundle cap is a hard structural limit, so the resolved budget is clamped to
|
|
949
|
+
* 8 000 bytes. A fraction that asks for MORE lowers nothing and raises nothing; a fraction that asks
|
|
950
|
+
* for less takes effect exactly. When the clamp costs a skill its description, both numbers ride the
|
|
951
|
+
* `skills.listing_truncated` notice.
|
|
952
|
+
*
|
|
953
|
+
* ⚠️ A model declaring a window SMALLER than 200 000 tokens now gets a proportionally smaller listing
|
|
954
|
+
* budget than the flat 8 000 bytes that preceded this seat.
|
|
955
|
+
*/
|
|
956
|
+
skillListingBudgetFraction?: number;
|
|
957
|
+
/**
|
|
958
|
+
* Per-skill description cap inside the `<skills>` listing, in code units, ellipsis included. Absent ⇒
|
|
959
|
+
* `1536`. Must be a positive integer; anything else REFUSES with
|
|
960
|
+
* `config.skill_listing_budget_invalid`. A description longer than the cap is truncated to exactly
|
|
961
|
+
* this many units; lowering it is how a deployment fits more skills' descriptions inside one budget.
|
|
962
|
+
*/
|
|
963
|
+
skillListingMaxDescChars?: number;
|
|
938
964
|
/**
|
|
939
965
|
* design/129 — lifetime scope for BACKGROUND children (Agent/Fork `run_in_background`). Default `"task"`:
|
|
940
966
|
* the parent runTask's terminal reaps them (design/115 — no orphans burning tokens; turn-end killed
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AskOrigin } from "./ask-origin.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type Settlement } from "./gate-outcome.js";
|
|
3
3
|
/** A tool call presented to a policy before it executes. */
|
|
4
4
|
/** design/388 B6 — the per-call face (see {@link ToolCallRequest.face}). */
|
|
5
5
|
export interface ToolCallFace {
|
|
@@ -310,6 +310,11 @@ export type PermissionResult = {
|
|
|
310
310
|
* in the one direction the stamp cannot reach (a caller composing its own ask) the claim is
|
|
311
311
|
* tightening-only: it can make an ask outlive a rule, never turn one into an allow. */
|
|
312
312
|
probeMandated?: true;
|
|
313
|
+
/** The probe named a directory that would clear this ask
|
|
314
|
+
* ({@link import("./types.js").ReversibilityVerdict.readRootCandidate}). Display metadata like the two
|
|
315
|
+
* `probe*` above; ENGINE-STAMPED and VALIDATED at the tighten, and deliberately NOT merged onto an ask
|
|
316
|
+
* another layer raised, which a grant leaves standing. */
|
|
317
|
+
probeReadRootCandidate?: import("./types.js").ReadRootGrantCandidate;
|
|
313
318
|
/** An EXPLICIT `ask` permission rule matched this call (design/127 DSL — the CC `alwaysAskRules`
|
|
314
319
|
* shape); carries the matched rule's text. Present ⇔ a rule someone WROTE says "ask about this",
|
|
315
320
|
* never for a `defaultAction:"ask"` fallback (an unmatched call is default-closed posture, not a
|
|
@@ -1013,6 +1018,15 @@ export interface AskRequest {
|
|
|
1013
1018
|
* structure: render the entries as data, never re-derive structure by splitting or joining them.
|
|
1014
1019
|
* The durable park route carries the same value as `RiskDescriptor.probeCause`. */
|
|
1015
1020
|
readonly probeCause?: import("./checkpoint-store.js").ProbeCause;
|
|
1021
|
+
/**
|
|
1022
|
+
* The ask-facing face of {@link import("./types.js").ReversibilityVerdict.readRootCandidate}, which owns
|
|
1023
|
+
* the meaning, the absence reading and the spelling contract: PRESENT ⇒ declaring this one directory
|
|
1024
|
+
* clears THIS ask, so a card can offer "add it and stop being asked" beside "yes, this once". Filled at
|
|
1025
|
+
* the synchronous MINT station by the tighten that ran the probe — the three INHERITED-lane sites present
|
|
1026
|
+
* an ancestor's decision from upstream of it and carry none, and the durable park row has NO twin: a
|
|
1027
|
+
* parked out-of-root ask renders the confirmation alone.
|
|
1028
|
+
*/
|
|
1029
|
+
readonly readRootCandidate?: import("./types.js").ReadRootGrantCandidate;
|
|
1016
1030
|
/** design/252 G-2: the RULE-PROVENANCE evidence behind this ask — the org snapshot revision and the
|
|
1017
1031
|
* matched rules' own identity keys, each present as a value or as a named absence (see
|
|
1018
1032
|
* {@link AskRuleEvidence}). Present on EVERY ask the engine mints, so a consumer never has to tell a
|
|
@@ -1121,9 +1135,10 @@ export interface AskRequest {
|
|
|
1121
1135
|
* probe declared structural — for the built-in shell probe, a read outside the directories this
|
|
1122
1136
|
* session declared. A surface must NOT point at rule-writing here — "allow rules silence the
|
|
1123
1137
|
* classifier's questions, never a mandated one", so a rule minted in answer to this line would
|
|
1124
|
-
* never take effect where it was minted.
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1138
|
+
* never take effect where it was minted. Whether a DIRECTORY grant is worth pointing at is not this
|
|
1139
|
+
* seat's to say: it is answered per ask by {@link readRootCandidate}, which is present exactly when
|
|
1140
|
+
* widening the session's read directories would clear this one and absent for every other member of
|
|
1141
|
+
* the family — so a surface reads that seat and never infers an answer from this word;
|
|
1127
1142
|
* · `"shadowed"` — a rule the person already wrote is speaking and does not clear this ask (a
|
|
1128
1143
|
* matching allow rule that the lane refuses to honor here, or their own explicit `ask` rule);
|
|
1129
1144
|
* · `"lane_cannot_speak"` — the lane is armed and this ask IS clearable, but the offer lane
|
package/dist/core/tool-policy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { brandPolicyAskClass } from "./ask-class.js";
|
|
4
|
+
import { decidersNote, settledByPerson } from "./gate-outcome.js";
|
|
4
5
|
import { sanitizeAutoModeArmingRecipe } from "./auto-mode-arming.js";
|
|
5
6
|
import { join, normalize as normalizePath, sep } from "node:path";
|
|
6
7
|
import { BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName } from "../tools/fs/index.js";
|
|
@@ -18,9 +19,6 @@ const DECISION_REASON_SET = new Set(DECISION_REASONS);
|
|
|
18
19
|
function settledByNobody(kind) {
|
|
19
20
|
return { kind, who: { party: "none" }, when: Date.now() };
|
|
20
21
|
}
|
|
21
|
-
function settledByPerson(kind, approver, note) {
|
|
22
|
-
return { kind, who: { party: "person", ...(approver !== undefined ? { approver } : {}) }, when: Date.now(), ...(note !== undefined ? { note } : {}) };
|
|
23
|
-
}
|
|
24
22
|
const ENGINE_SETTLEMENTS = new WeakMap();
|
|
25
23
|
function attestEngineSettlement(d, record, call) {
|
|
26
24
|
const s = record.settlement;
|
|
@@ -1273,7 +1271,7 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
|
|
|
1273
1271
|
settlement: settledByNobody("approver_contract"),
|
|
1274
1272
|
};
|
|
1275
1273
|
}
|
|
1276
|
-
reasonText = typeof suppliedReason === "string"
|
|
1274
|
+
reasonText = typeof suppliedReason === "string" ? suppliedReason : undefined;
|
|
1277
1275
|
}
|
|
1278
1276
|
catch (err) {
|
|
1279
1277
|
return {
|
|
@@ -1283,15 +1281,16 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
|
|
|
1283
1281
|
settlement: settledByNobody("approver_contract"),
|
|
1284
1282
|
};
|
|
1285
1283
|
}
|
|
1284
|
+
const note = decidersNote(reasonText);
|
|
1286
1285
|
return {
|
|
1287
1286
|
action: "deny",
|
|
1288
1287
|
message: supplied === "timeout"
|
|
1289
|
-
? `approval for "${req.toolName}" was not answered before the approver's own window elapsed: ${req.message}${
|
|
1290
|
-
: humanRefusalMessage(req,
|
|
1288
|
+
? `approval for "${req.toolName}" was not answered before the approver's own window elapsed: ${req.message}${note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", note, REVIEWER_NOTE_MAX_BODY)}` : ""}`
|
|
1289
|
+
: humanRefusalMessage(req, note),
|
|
1291
1290
|
decisionReason: "mode",
|
|
1292
1291
|
settlement: supplied === "timeout"
|
|
1293
1292
|
? { kind: "approval_window_expired", who: { party: "host" }, when: Date.now() }
|
|
1294
|
-
: settledByPerson("human_refused", attribution.approver,
|
|
1293
|
+
: settledByPerson("human_refused", attribution.approver, note),
|
|
1295
1294
|
};
|
|
1296
1295
|
}
|
|
1297
1296
|
if (suppliedEdit === undefined)
|
package/dist/core/tool-spec.d.ts
CHANGED
|
@@ -62,12 +62,45 @@ export interface ReversibilityVerdict {
|
|
|
62
62
|
* its own deployment a re-confirmation and can never turn an ask into an allow.
|
|
63
63
|
*
|
|
64
64
|
* The built-in shell probe sets it for exactly one family: a listed reader naming a path outside the
|
|
65
|
-
* session's allowed directories.
|
|
66
|
-
*
|
|
67
|
-
* it
|
|
68
|
-
*
|
|
65
|
+
* session's allowed directories. That demotion HAS a clearing channel — widening the declared read
|
|
66
|
+
* directories retires it — and where the probe can name the directory, {@link readRootCandidate} carries
|
|
67
|
+
* it. A mandated ask WITHOUT that member has no configuration behind it: the rest of the family (an
|
|
68
|
+
* operator `shellGate:"always"`, the tool's egress/irreversibility marks, an org demand, an out-of-root
|
|
69
|
+
* arm with no named directory) is confirm-or-refuse, and must not be worded as though a grant answers it.
|
|
69
70
|
*/
|
|
70
71
|
mandated?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* The directory-scoped GRANT that would clear THIS ask, when the probe can name one: PRESENT ⇒ the only
|
|
74
|
+
* thing between this call and a plain `reversible: true` verdict is the session's declared read
|
|
75
|
+
* containment, and adding {@link ReadRootGrantCandidate.dir} to it makes the same call run with no card.
|
|
76
|
+
*
|
|
77
|
+
* ABSENT is the ordinary world and no claim that nothing would clear the ask — it covers every demotion
|
|
78
|
+
* with no directory answer (a deny row, a read the walk could not read, an unenumerable expansion, a
|
|
79
|
+
* shape refused whatever the roots say) and every probe silent on this member. Read presence, never
|
|
80
|
+
* absence. ADVISORY display metadata, never adjudication input.
|
|
81
|
+
*/
|
|
82
|
+
readRootCandidate?: ReadRootGrantCandidate;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A directory-scoped grant a surface can offer on an approval card — see
|
|
86
|
+
* {@link ReversibilityVerdict.readRootCandidate} and `AskRequest.readRootCandidate`.
|
|
87
|
+
*
|
|
88
|
+
* @contract shell.read_boundary.grant_candidate_is_the_grant — {@link dir} is the string to ADD, not a
|
|
89
|
+
* string to show: a consumer that renders a shortened or home-relative form must send this exact spelling
|
|
90
|
+
* back when the person accepts. Core mints one spelling for both errands, and the promise is over THAT
|
|
91
|
+
* spelling reaching the directories the boundary compares against: a deployment that REWRITES a declared
|
|
92
|
+
* directory — canonicalizing a symlinked one included — must rewrite the command's spelling the same way,
|
|
93
|
+
* or it will compare a rewritten root against an unrewritten operand and ask again. Where the two differ
|
|
94
|
+
* the ask was unclearable before this seat existed and still is; the offer names the directory the command
|
|
95
|
+
* spelled, which is the only one derivable from the text.
|
|
96
|
+
*/
|
|
97
|
+
export interface ReadRootGrantCandidate {
|
|
98
|
+
/** An absolute, lexically folded directory in the boundary's own spelling; never a file or a pattern. */
|
|
99
|
+
readonly dir: string;
|
|
100
|
+
/** Always the literal `true` — a shape, not a value, so the seat cannot be widened into a "maybe this
|
|
101
|
+
* helps" hint without a visible type change. It claims THIS ask: not the deny layer (judged by pattern
|
|
102
|
+
* rather than by root, and unmoved by any grant), and not another ask the same call may raise. */
|
|
103
|
+
readonly clearsThisAsk: true;
|
|
71
104
|
}
|
|
72
105
|
export interface ToolSpec<TParams extends TSchema = TSchema> {
|
|
73
106
|
/**
|
package/dist/core/types.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { AgentTool, ThinkingLevel, ToolInputValidationContext, ToolInputVer
|
|
|
13
13
|
import type { Model, StreamFn } from "../internal/llm.js";
|
|
14
14
|
export type { ModelRef, ModelRole, RoleSpec, ModelRoles, Brain } from "./model-seat.js";
|
|
15
15
|
export type { StaleToolResultOffloadOptions, ImageInput, TaskSpec, ShellGateDoctrine } from "./task-spec.js";
|
|
16
|
-
export type { ToolEffect, ToolContentOrigin, ReversibilityVerdict, ToolSpec, ToolReturn, NestedUsage, NestedUsageAccum, ToolExecuteContext } from "./tool-spec.js";
|
|
16
|
+
export type { ToolEffect, ToolContentOrigin, ReversibilityVerdict, ReadRootGrantCandidate, ToolSpec, ToolReturn, NestedUsage, NestedUsageAccum, ToolExecuteContext } from "./tool-spec.js";
|
|
17
17
|
export type { BeforeWriteRequest, BeforeWriteResult, BeforeWriteHook, TrackEditRequest, TrackEditResult, TrackFileEditHook, FileEditedNotice, FileEditedHook, HandsBandOptions } from "./hands-band.js";
|
|
18
18
|
export type { AgentDefinition, SkillManifest, SkillSpec } from "./agent-definition.js";
|
|
19
19
|
export type { McpServerSpec, A2aServerSpec, McpElicitRequest, McpElicitResponse, OnElicit } from "./mcp-server-spec.js";
|
|
@@ -51,8 +51,13 @@ export interface WriteProtectionMatcher {
|
|
|
51
51
|
/** The resolved rows this judge was compiled from (canonical names — the disclosure face). */
|
|
52
52
|
readonly rows: readonly WriteProtectedRow[];
|
|
53
53
|
/** Judge ONE path spelling. Returns the first matching row (basename rows first, then segment,
|
|
54
|
-
* then segment-run — deterministic), or null.
|
|
55
|
-
|
|
54
|
+
* then segment-run — deterministic), or null.
|
|
55
|
+
*
|
|
56
|
+
* `cwd` = the call's LIVE tracked working directory, read at judgment time off the ONE ref the fs
|
|
57
|
+
* hand tools resolve with — the law every path guard here follows: judge the file the tool will write.
|
|
58
|
+
* Absent ⇒ judged exactly as given — the historical reading, and the honest answer for a host
|
|
59
|
+
* that tracks no cwd. */
|
|
60
|
+
matchPath(path: string, cwd?: string): WriteProtectedHit | null;
|
|
56
61
|
}
|
|
57
62
|
/**
|
|
58
63
|
* Compile the write-protection judge for a deployment configuration. Returns `undefined` when the
|
|
@@ -82,4 +87,4 @@ export declare function compileWriteProtection(entries?: readonly WriteProtected
|
|
|
82
87
|
*/
|
|
83
88
|
export declare function createWriteProtectionCheck(entries?: readonly WriteProtectedEntry[], opts?: {
|
|
84
89
|
dataRoot?: string;
|
|
85
|
-
}): ((toolName: string, args: unknown, face?: ToolCallLike["face"]) => WriteProtectedHit | null) | undefined;
|
|
90
|
+
}): ((toolName: string, args: unknown, face?: ToolCallLike["face"], cwd?: string) => WriteProtectedHit | null) | undefined;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isWinFormPath, writeTargetPath, isUncPath } from "../tools/fs/safety.js";
|
|
1
|
+
import { isWinFormPath, writeTargetPath, isUncPath, isAbsoluteForFamily, joinForFamily, pathFamilyOf } from "../tools/fs/safety.js";
|
|
2
2
|
import { isProtectedWrite } from "./tool-registry.js";
|
|
3
3
|
import { dataRootSpellings } from "./sensitive-path-policy.js";
|
|
4
4
|
const freezeTable = (rows) => Object.freeze(rows.map((r) => Object.freeze(r)));
|
|
@@ -182,8 +182,11 @@ export function compileWriteProtection(entries, opts) {
|
|
|
182
182
|
}
|
|
183
183
|
return {
|
|
184
184
|
rows,
|
|
185
|
-
matchPath(path) {
|
|
186
|
-
const
|
|
185
|
+
matchPath(path, cwd) {
|
|
186
|
+
const base = typeof cwd === "string" && cwd.length > 0 ? cwd : undefined;
|
|
187
|
+
const family = pathFamilyOf({ cwd: base });
|
|
188
|
+
const spelled = base !== undefined && family !== undefined && !isAbsoluteForFamily(family, path) ? joinForFamily(family, base, path) : path;
|
|
189
|
+
const judged = relativeUnderDataRoot(spelled, rootViews) ?? spelled;
|
|
187
190
|
const judge = (raw) => {
|
|
188
191
|
if (raw.length === 0)
|
|
189
192
|
return null;
|
|
@@ -213,13 +216,13 @@ export function createWriteProtectionCheck(entries, opts) {
|
|
|
213
216
|
const matcher = compileWriteProtection(entries, opts);
|
|
214
217
|
if (matcher === undefined)
|
|
215
218
|
return undefined;
|
|
216
|
-
return (toolName, args, face) => {
|
|
219
|
+
return (toolName, args, face, cwd) => {
|
|
217
220
|
const call = { toolName, args, ...(face !== undefined ? { face } : {}) };
|
|
218
221
|
if (!isProtectedWrite(call))
|
|
219
222
|
return null;
|
|
220
223
|
const path = writeTargetPath(call);
|
|
221
224
|
if (typeof path !== "string" || path.length === 0)
|
|
222
225
|
return null;
|
|
223
|
-
return matcher.matchPath(path);
|
|
226
|
+
return matcher.matchPath(path, cwd);
|
|
224
227
|
};
|
|
225
228
|
}
|