@sema-agent/core 7.18.0 → 7.20.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 +276 -2
- package/dist/agents/subagent.js +12 -20
- package/dist/agents/teacher.js +2 -5
- package/dist/agents/verify.js +21 -10
- package/dist/brain/anthropic.js +8 -3
- package/dist/brain/open-responses.js +12 -4
- package/dist/brain/openai.js +7 -2
- package/dist/brain/reasoning.d.ts +13 -13
- package/dist/brain/reasoning.js +2 -2
- package/dist/brain/stream-engine.d.ts +4 -0
- package/dist/brain/stream-engine.js +6 -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 +74 -26
- package/dist/core/auto-mode.js +80 -11
- package/dist/core/checkpoint-store.d.ts +7 -0
- package/dist/core/checkpoint-store.js +2 -2
- package/dist/core/child-clamp-seats.d.ts +79 -0
- package/dist/core/child-clamp-seats.js +29 -0
- package/dist/core/engine-notice.d.ts +89 -41
- package/dist/core/fs-write-gate-policy.d.ts +17 -9
- package/dist/core/fs-write-gate-policy.js +51 -4
- package/dist/core/gate-bounded-await.d.ts +38 -0
- package/dist/core/gate-bounded-await.js +53 -0
- package/dist/core/gate-fold.js +20 -48
- package/dist/core/gate-lanes.js +17 -9
- package/dist/core/gate-outcome.d.ts +34 -6
- package/dist/core/gate-outcome.js +7 -0
- package/dist/core/governance-codes.d.ts +16 -1
- package/dist/core/governance-codes.js +18 -0
- package/dist/core/hooks.d.ts +8 -8
- package/dist/core/memory-engine/engine.d.ts +14 -9
- package/dist/core/memory-engine/engine.js +21 -8
- package/dist/core/memory-engine/layout.d.ts +31 -0
- package/dist/core/memory-engine/layout.js +68 -3
- package/dist/core/memory-engine/types.d.ts +24 -0
- package/dist/core/memory.d.ts +19 -0
- package/dist/core/memory.js +12 -0
- package/dist/core/read-root-candidate.d.ts +12 -0
- package/dist/core/read-root-candidate.js +24 -0
- package/dist/core/remote-env.d.ts +32 -0
- package/dist/core/remote-env.js +4 -1
- package/dist/core/roles.d.ts +9 -1
- package/dist/core/roles.js +4 -0
- package/dist/core/runner/advertised-writable-dirs.d.ts +59 -1
- package/dist/core/runner/advertised-writable-dirs.js +51 -3
- package/dist/core/runner/announce-once-ledger.d.ts +15 -0
- package/dist/core/runner/announce-once-ledger.js +8 -0
- package/dist/core/runner/classifier-ladder.d.ts +154 -11
- package/dist/core/runner/classifier-ladder.js +170 -10
- package/dist/core/runner/contracts.d.ts +46 -1
- package/dist/core/runner/denial-limit-arms.d.ts +14 -1
- package/dist/core/runner/denial-limit-arms.js +7 -6
- package/dist/core/runner/env-capability-census.d.ts +30 -0
- package/dist/core/runner/env-capability-census.js +38 -0
- package/dist/core/runner/forward-sink.d.ts +32 -0
- package/dist/core/runner/forward-sink.js +41 -0
- package/dist/core/runner/gate-exit.d.ts +4 -0
- package/dist/core/runner/gate-exit.js +4 -1
- package/dist/core/runner/git-status-frame.d.ts +0 -9
- package/dist/core/runner/listing-fold.d.ts +57 -0
- package/dist/core/runner/listing-fold.js +75 -0
- 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-execution-env.d.ts +1 -1
- package/dist/core/runner/prepare-execution-env.js +2 -0
- package/dist/core/runner/prepare-gate-stations.d.ts +8 -0
- package/dist/core/runner/prepare-gate-stations.js +6 -2
- package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
- package/dist/core/runner/prepare-hands-readface.js +3 -5
- package/dist/core/runner/prepare-listings.d.ts +3 -0
- package/dist/core/runner/prepare-listings.js +4 -2
- package/dist/core/runner/prepare-memory.js +7 -1
- package/dist/core/runner/prepare-policy-chain.js +5 -5
- 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-prompt-inputs.d.ts +2 -2
- package/dist/core/runner/prepare-run-refs.d.ts +4 -2
- package/dist/core/runner/prepare-run-refs.js +5 -4
- package/dist/core/runner/prepare-task.js +8 -6
- package/dist/core/runner/resume-apply.js +3 -3
- package/dist/core/runner/run-clock-and-content.js +3 -7
- package/dist/core/runner/run-harness-handlers.js +10 -10
- package/dist/core/runner/run-leg.js +31 -16
- package/dist/core/runner/run-settle-and-teardown.js +2 -5
- package/dist/core/runner/run-turn-boundary.js +44 -25
- package/dist/core/runner/runtask.js +2 -1
- package/dist/core/runner/synthetic-tools.d.ts +41 -29
- package/dist/core/runner/synthetic-tools.js +25 -40
- package/dist/core/runner/task-root-canonical.d.ts +45 -0
- package/dist/core/runner/task-root-canonical.js +18 -0
- package/dist/core/runner/turn-attachments.d.ts +160 -53
- package/dist/core/runner/turn-attachments.js +153 -51
- package/dist/core/safe-notify.d.ts +5 -0
- package/dist/core/safe-notify.js +24 -2
- package/dist/core/sensitive-path-policy.d.ts +84 -17
- package/dist/core/sensitive-path-policy.js +37 -19
- package/dist/core/skill-listing-budget.d.ts +106 -0
- package/dist/core/skill-listing-budget.js +57 -0
- package/dist/core/skills-directory.js +17 -10
- package/dist/core/task-registry.js +2 -1
- package/dist/core/task-spec.d.ts +29 -2
- package/dist/core/tool-policy.d.ts +31 -5
- package/dist/core/tool-policy.js +45 -32
- package/dist/core/tool-spec.d.ts +37 -4
- package/dist/core/types.d.ts +1 -1
- package/dist/core/write-protect.d.ts +105 -20
- package/dist/core/write-protect.js +89 -32
- package/dist/index.d.ts +8 -7
- package/dist/index.js +6 -5
- package/dist/orchestration/workflow-primitives.js +10 -20
- package/dist/stores/file/memory-store.d.ts +5 -2
- package/dist/stores/file/memory-store.js +8 -4
- package/dist/tools/fs/bash-readonly-classifier.d.ts +40 -2
- package/dist/tools/fs/bash-readonly-classifier.js +376 -41
- package/dist/tools/fs/fs-bash.d.ts +1 -1
- package/dist/tools/fs/fs-bash.js +19 -28
- package/dist/tools/fs/fs-shared.d.ts +22 -0
- package/dist/tools/fs/fs-shared.js +30 -0
- package/dist/tools/fs/index.d.ts +1 -1
- package/dist/tools/fs/index.js +1 -1
- 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 +2 -1
- package/test/export-surface.snapshot.json +31 -5
- package/wire-changes/core@7.18.0.json +1273 -0
- package/wire-changes/core@7.19.0.json +457 -0
- package/wire-changes/core@7.20.0.json +568 -0
|
@@ -10,38 +10,62 @@ export interface EngineNotice {
|
|
|
10
10
|
* `autoCompactTokens` ABOVE its physical window; the trigger-side geometry was clamped to the
|
|
11
11
|
* physical window (an autocompact window only ever lowers the trigger) and the bad value is
|
|
12
12
|
* announced once per prepared task; `detail: { modelId, declaredAutoCompactTokens,
|
|
13
|
-
* physicalWindow, sessionId, runId }` — `runId`
|
|
14
|
-
*
|
|
15
|
-
* runs clamping the same model used to mint byte-identical lines.
|
|
13
|
+
* physicalWindow, sessionId, runId }` — `runId` is what makes the "once per prepared task" claim
|
|
14
|
+
* checkable, the session id beside it being shared by every run of the session.
|
|
16
15
|
* - `"config.env_timeout_discarded"` — a Bash timeout knob (option or env) held a value that is not
|
|
17
16
|
* the value in force; `detail: { knob, raw, usedMs }`.
|
|
18
17
|
* - `"config.materialize_env_discarded"` — `SEMA_TOOL_MATERIALIZE_STRATEGY` held a value outside the
|
|
19
18
|
* closed set in a seat where it is not in force; `detail: { raw, specStrategy? }`.
|
|
19
|
+
* - `"config.task_root_not_canonical"` (#759) — the task root this run was handed is not its own
|
|
20
|
+
* canonical path (a symlinked directory; `/var` → `/private/var` on macOS). Containment is judged
|
|
21
|
+
* on the CANONICAL spelling, so the deployment and the engine reason about two different
|
|
22
|
+
* directories: a target the host considers inside the root can be judged outside it. The run is
|
|
23
|
+
* unchanged — announce, never narrow, never repair — and the fix is to hand the canonical path in.
|
|
24
|
+
* Audience `operator` (a deployment wiring fact). `detail: { given, canonical, sessionId, runId }`
|
|
25
|
+
* — both spellings, so a consumer can diff them without guessing which one it holds. Once per
|
|
26
|
+
* prepared leg, and never for a root that already is its own canonical form (its absence is the
|
|
27
|
+
* claim that the two agree).
|
|
20
28
|
* - `"config.tool_card_undeclared"` (design/388 L4) — a tool minted a `details.type` card its definition
|
|
21
29
|
* (or its caller declaration's `renderHints.resultCards`) never declared; the card was dropped from
|
|
22
30
|
* `tool_end.structured`; once per leg per type; `detail: { type, sessionId, runId }`.
|
|
23
31
|
* - `"config.tool_face_undeclared"` / `"config.tool_face_invalid"` (design/388 §2.7) — an MCP server's
|
|
24
32
|
* `toolFaces` overlay names a tool the listing does not carry / does not fit the tool's materialized
|
|
25
33
|
* schema (the tool mounts bare); once per leg per tool; `detail: { server, tool, problem?, sessionId, runId }`.
|
|
34
|
+
* - `"agents.listing_truncated"` — the agent-type roster frame did not fit the same listing budget:
|
|
35
|
+
* at least one type is listed name-only (`nameOnly`) or not at all (`dropped`). Audience `operator`.
|
|
36
|
+
* `detail: { budgetBytes, total, kept, nameOnly: [name], dropped: [name], sessionId }`; the same
|
|
37
|
+
* firing rule as its skills sibling below (once per delivered frame, on the carrying message's receipt).
|
|
38
|
+
* - `"skills.listing_truncated"` — the `<skills>` disclosure block did not fit its byte budget, so
|
|
39
|
+
* at least one skill is listed without its description (`nameOnly`) or, in the pathological case,
|
|
40
|
+
* not listed at all (`dropped`); the `Skill` tool still serves every one of them. Audience
|
|
41
|
+
* `operator` — raising `TaskSpec.skillListingBudgetFraction`, lowering `skillListingMaxDescChars`
|
|
42
|
+
* or declaring fewer skills are all deployment acts. `detail: { budgetBytes, descMaxChars, total,
|
|
43
|
+
* kept, nameOnly: [name], dropped: [name], sessionId }` — `budgetBytes` IS the budget in force;
|
|
44
|
+
* no ceiling stands above the declared fraction, so no second number rides beside it.
|
|
45
|
+
* Announced on the RECEIPT of the message that carried the frame — the leg's first user message, or
|
|
46
|
+
* the boundary steer that a drift (or a resume leg's initial listing) rides — once per delivered
|
|
47
|
+
* frame the budget actually degraded, and never for one that fit. Every count is read off that frame, so
|
|
48
|
+
* `total` is the whole roster on an initial listing and the newly announced set on a drift frame.
|
|
49
|
+
* A run whose deployment turned the listing off (`attachments.skillsListing: false`) emits nothing,
|
|
50
|
+
* since nothing is rendered. There is NO dedup key: each notice is one delivered frame`s cost, and
|
|
51
|
+
* a frame is delivered (and committed) once.
|
|
26
52
|
* - `"tool_result.offload_put_failed"` (#167) — a clear-with-offload persist's fire-and-forget put
|
|
27
53
|
* failed; THIS attempt stored nothing (the failure arm reports, it never re-inserts under the
|
|
28
54
|
* ref) — an earlier attempt of the same idempotent re-put may already have stored the row, so
|
|
29
55
|
* the notice claims a failed write, not an empty ref;
|
|
30
|
-
* `detail: { ref, sessionId, cause, occurrenceId }`.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* genuine losses of one ref were byte-identical notices. Dedup key: (code, occurrenceId).
|
|
56
|
+
* `detail: { ref, sessionId, cause, occurrenceId }`. Per-occurrence, not
|
|
57
|
+
* per-process-deduplicated: each failed write is a distinct fact, and `ref` names the ROW rather
|
|
58
|
+
* than the attempt, so the uuidv7 `occurrenceId` minted at the failure is what tells two genuine
|
|
59
|
+
* losses of one ref apart. Dedup key: (code, occurrenceId).
|
|
35
60
|
* - `"workflow.governance_key_stripped"` (#235) — the fields of an LLM-authored workflow `agent(spec)`
|
|
36
61
|
* that did NOT cross the governed default-deny whitelist: an unrecognized/control-plane field, or a
|
|
37
62
|
* `readFace` value that is not the containment-tightening one. The spawn PROCEEDS on the deployment
|
|
38
63
|
* baseline (the strip is not a refusal in this window), which is why the drop is announced;
|
|
39
64
|
* `detail: { total, stripped: [{ key, reason }], omitted?, occurrenceId }`, the rendered key list
|
|
40
65
|
* bounded in count and length because the names come from the untrusted script. One aggregated
|
|
41
|
-
* notice per governed child build, not de-duplicated across builds: each spec is a distinct fact
|
|
42
|
-
* `occurrenceId`
|
|
43
|
-
*
|
|
44
|
-
* (code, occurrenceId).
|
|
66
|
+
* notice per governed child build, not de-duplicated across builds: each spec is a distinct fact,
|
|
67
|
+
* and the uuidv7 `occurrenceId` minted at the announcement is what carries that claim to a consumer
|
|
68
|
+
* that would otherwise read two builds of one spec as one repeated notice. Dedup key: (code, occurrenceId).
|
|
45
69
|
* - `"workflow.agent_option_ignored"` (#505 ①) — the OPTIONS-object twin of the row above: keys of
|
|
46
70
|
* `agent(spec, opts)`'s SECOND argument that were not applied (unrecognized, or a known option
|
|
47
71
|
* whose value its type check rejected — `label: 42`). The spawn PROCEEDS without them, which is
|
|
@@ -51,6 +75,21 @@ export interface EngineNotice {
|
|
|
51
75
|
* key: (code, occurrenceId). NOT the whole family: a SPEC field written on the options object
|
|
52
76
|
* (`model`, `modelName`, `thinking`, …) is REFUSED at the membrane instead — a silently ignored
|
|
53
77
|
* `model` runs a different agent than the author asked for.
|
|
78
|
+
* - `"config.forward_sink_failed"` — the deployment's opt-in subagent display sink
|
|
79
|
+
* (`RunInternals.onForwardEvent`) failed and the frame it was handed was dropped: it threw, or —
|
|
80
|
+
* since an `async` function satisfies that `=> void` seat — it returned a promise that rejected.
|
|
81
|
+
* The run is untouched (forwarding is a render channel, and delivery is never awaited, so a sink
|
|
82
|
+
* that never settles is not a failure and announces nothing). `detail: { site, cause }` — `site` is
|
|
83
|
+
* the engine-owned site name the failures are counted under, `cause` the thrown value's message.
|
|
84
|
+
* Audience `operator`: the sink is deployment wiring and nobody else can repair it. Dedup unit:
|
|
85
|
+
* once per RUN (the counters ride `RunInternals.forwardScope`, which every copy of a run's
|
|
86
|
+
* internals carries), later failures of the same run counted only — a sink that fails on every
|
|
87
|
+
* frame therefore costs ONE line, not one per frame.
|
|
88
|
+
* - `"memory.restore_withheld"` — a memory containment DID NOT write a committed baseline back: another
|
|
89
|
+
* writer held the path when the restore reached it, and those newer bytes were left standing.
|
|
90
|
+
* Containment is unaffected and the committed copy is still in the store, so reconciling the two is
|
|
91
|
+
* the host's act (audience `operator`). `detail: { paths: [{ path, entryId?, rev? }], sessionId? }`,
|
|
92
|
+
* `rev` = the declined generation. Dedup unit: once per containment PASS, never per path.
|
|
54
93
|
* - `"mcp.revocation_probe_failed"` (design/338) — the deployment's `mcpRevocations.isRevoked`
|
|
55
94
|
* probe threw; MCP dispatch FAILS OPEN (revocation is a tightening face) and this announces
|
|
56
95
|
* once per materialization (a resume re-materializes and may announce again).
|
|
@@ -74,10 +113,9 @@ export interface EngineNotice {
|
|
|
74
113
|
* { models, tiers, occurrenceId }` — key COUNTS only, never the catalog itself. In-flight tasks
|
|
75
114
|
* finish on the models they resolved at prepare (natural snapshot); every later prepare resolves
|
|
76
115
|
* against the new generation. A failed swap (illegal tier binding, a refused candidate) throws
|
|
77
|
-
* atomically and mints nothing.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* identical lines. Dedup key: (code, occurrenceId).
|
|
116
|
+
* atomically and mints nothing. The verb carries no run and no session and the payload is a pair
|
|
117
|
+
* of counts, so the uuidv7 `occurrenceId` minted at the announcement is what tells two swaps of the
|
|
118
|
+
* same shape apart. Dedup key: (code, occurrenceId).
|
|
81
119
|
* - `"config.read_face_swapped"` — `Runner.swapDeps` replaced the deployment's read-face
|
|
82
120
|
* declaration (`RunnerDeps.readFace`, the `readFace` seat of the swappable set) without a
|
|
83
121
|
* restart. `detail: { from, to, occurrenceId }` — the seat's previous and new value, each
|
|
@@ -86,8 +124,7 @@ export interface EngineNotice {
|
|
|
86
124
|
* read-only mount) still runs per prepare. In-flight legs keep the face they prepared under;
|
|
87
125
|
* every later prepare resolves against the new declaration. A candidate outside the value gate
|
|
88
126
|
* (`assertContainmentFaceValue`) throws atomically and mints nothing. Own `occurrenceId` for the same
|
|
89
|
-
* reason as the catalog line (no run, no session, a two-word payload). Dedup key:
|
|
90
|
-
* (code, occurrenceId).
|
|
127
|
+
* reason as the catalog line (no run, no session, a two-word payload). Dedup key: (code, occurrenceId).
|
|
91
128
|
* - `"config.write_face_swapped"` — the same line for the WRITE seat (`RunnerDeps.writeFace`),
|
|
92
129
|
* same payload, same laws. A SEPARATE code rather than a face field on the line above: the audience
|
|
93
130
|
* table and every consumer table are keyed by code.
|
|
@@ -98,10 +135,9 @@ export interface EngineNotice {
|
|
|
98
135
|
* `detail: { seat, from, to, cause, fixHint, sessionId?, runId? }` — `cause` is the refusal code
|
|
99
136
|
* (`route.credential_mismatch` / `route.credential_missing`); `sessionId` (#433, additive)
|
|
100
137
|
* rides when the adjudicating seat knows its session, and `runId` (#499, additive) names the
|
|
101
|
-
* INVOCATION whose seats were being resolved — both correlation keys only, the audience
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* tells one task falling back twice apart from two tasks falling back once each.
|
|
138
|
+
* INVOCATION whose seats were being resolved — both correlation keys only, the audience stays
|
|
139
|
+
* `"operator"` (see the registry's entitlement-not-routability note), and `runId` is what tells one
|
|
140
|
+
* task falling back twice apart from two tasks falling back once each.
|
|
105
141
|
* Explicitly-named models never mint this: they refuse at the brain's request gate instead.
|
|
106
142
|
* - `"route.classifier_unroutable"` (same pre-flight, the other POSTURE) — the derived classifier seat
|
|
107
143
|
* failed the same pairing pre-flight while the deployment had declared NO fallback
|
|
@@ -118,6 +154,20 @@ export interface EngineNotice {
|
|
|
118
154
|
* `detail: { from, to, cause, sessionId?, runId? }` — `cause` is the `AutoModeFallbackCause` word, the
|
|
119
155
|
* two ids are the rungs. Dedup key: (code, from, cause) PER RUN; a second CAUSE on the same rung is
|
|
120
156
|
* its own line.
|
|
157
|
+
* - `"classifier.role_defaulted"` (`src/core/runner/classifier-ladder.ts`, the seat resolver) — no
|
|
158
|
+
* `classifier` role names a model, so another model STANDS IN for the seat. `detail: { model, reason,
|
|
159
|
+
* thinkingOffSpelled, sessionId?, runId? }` — `reason` ∈ `tier_below_main` (the nearest directly-bound
|
|
160
|
+
* tier below the run's main model) / `tier_table` (the static `flash` row, a main bound to no tier) /
|
|
161
|
+
* `role_fallback` (the `summarize → default` chain, no tiers) / `main_only` (the main model is the only
|
|
162
|
+
* model there is) / `role_unresolvable` (the role named a model the catalog cannot resolve; the main
|
|
163
|
+
* stands in); `thinkingOffSpelled` = whether that model's wire can carry a disable key. Minted
|
|
164
|
+
* AFTER the route pre-flight, so it names the seat actually used. Once per run.
|
|
165
|
+
* - `"classifier.contract_broken"` (same file, the round observer) — ONE model answered
|
|
166
|
+
* `AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER` CONSECUTIVE classifications without a verdict (each already
|
|
167
|
+
* re-asked once by the leg). `detail: { model, rung, consecutive, sessionId?, runId? }` — `rung` is the
|
|
168
|
+
* ladder position (0 the seat, 1 the model behind it), since two rungs may share an id. A ruling from
|
|
169
|
+
* that rung resets its count; an `unavailable` round neither counts nor resets. Once per run per rung,
|
|
170
|
+
* and by construction before the denial limit's third block hands the user its card.
|
|
121
171
|
* - `"route.base_url_changed_key_unchanged"` (key↔URL pairing) — `Runner.swapDeps` moved a
|
|
122
172
|
* same-name entry's `baseUrl` while its Model-visible credential half (auth-bearing headers)
|
|
123
173
|
* did not change: legal (a provider changing domains), but worth one loud line — if the
|
|
@@ -125,10 +175,9 @@ export interface EngineNotice {
|
|
|
125
175
|
* refusal; per-model-hook credentials are invisible to the catalog swap, so the notice is
|
|
126
176
|
* deliberately conservative (it may fire when a hook-side credential DID change in lockstep).
|
|
127
177
|
* One aggregated notice per swap; `detail: { entries: [{ modelId, from, to }], total,
|
|
128
|
-
* occurrenceId }` (rendered list bounded, total always exact)
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* Dedup key: (code, occurrenceId).
|
|
178
|
+
* occurrenceId }` (rendered list bounded, total always exact); the `occurrenceId` is minted at this
|
|
179
|
+
* announcement, independently of the `config.models_swapped` line's own, because the bounded render
|
|
180
|
+
* makes two swaps moving the same first entries identical. Dedup key: (code, occurrenceId).
|
|
132
181
|
* - `"config.read_face_deployment_clamped"` (#237) — a deployment-wide `readFace: "open"` is not
|
|
133
182
|
* in force beside a read-only (verifier) mount: it clamps to "roots" without throwing
|
|
134
183
|
* (stricter-wins; the clamp verdict stands, only its occurrence was undisclosed). Announced
|
|
@@ -164,14 +213,17 @@ export interface EngineNotice {
|
|
|
164
213
|
* threw: the `Artifact` tool is not mounted for the task and this is the loud half (an absent seat
|
|
165
214
|
* is silent; a broken one must not look the same). Once per prepared leg;
|
|
166
215
|
* `detail: { problem, sessionId, runId }`.
|
|
216
|
+
* - `"config.execution_env_capability_invalid"` — a remote env DECLARES a boolean capability key
|
|
217
|
+
* (`isolation`, `suspendable`, `canonicalPathAuthoritative`) with a non-boolean value; ABSENCE is a
|
|
218
|
+
* supported reading of each and stays silent. Once per key per run; `detail: { key, valueType }`.
|
|
167
219
|
* - `"config.durable_gate_unavailable"` — a durable-approval MANDATE is in force on a leg with NO
|
|
168
220
|
* checkpoint store: the per-principal `forceDurableGate` entitlement, or the task's own
|
|
169
221
|
* `durableApproval` opt-in with no live approver seat (`durableMandateSourceOf`, the predicate the
|
|
170
222
|
* leg's children inherit). A mandate with no park facility cannot be honored: no ask of the leg can suspend, so each one resolves on the LIVE
|
|
171
223
|
* chain — a live approver / question face answers in-stream, and with none the ask is denied
|
|
172
224
|
* fail-closed — and NO durable approval record is written. The ask routing itself is not
|
|
173
|
-
* changed by the notice (a store-less leg never could park); the notice is
|
|
174
|
-
*
|
|
225
|
+
* changed by the notice (a store-less leg never could park); the notice is its loud half,
|
|
226
|
+
* disclosed before the first ask of the leg. Audience
|
|
175
227
|
* `"user"` (the person whose asks will not be recorded is entitled to that; the operator hears
|
|
176
228
|
* it through the sink as always). Once per prepared task leg — a resume or a delegated child
|
|
177
229
|
* is its own leg and says so again; `detail: { sessionId, runId, principal?, cause, source,
|
|
@@ -194,12 +246,10 @@ export interface EngineNotice {
|
|
|
194
246
|
* (`sessionId` = #433's routing half for these two `"user"`-audience rows; `runId` = #499's
|
|
195
247
|
* per-run identity, the key this account is actually about — see
|
|
196
248
|
* {@link undrainedUserInputNotices}; all omitted when the caller has none, never fabricated).
|
|
197
|
-
* Per-run, at most once per family (the terminal sweep is a single site) — which is
|
|
198
|
-
* `taskId`/`sessionId` cannot key it: both answer the same value for two runs of one session
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* to empty both queues before agent_end could count them, so the one loss path an operator most
|
|
202
|
-
* needs to hear about was the one path that stayed silent. ② On a DURABLE PARK the verdict is
|
|
249
|
+
* Per-run, at most once per family (the terminal sweep is a single site) — which is why
|
|
250
|
+
* `taskId`/`sessionId` cannot key it: both answer the same value for two runs of one session.
|
|
251
|
+
* ① The family fires on the INTERRUPT path too, `abort()` being barred from emptying either queue
|
|
252
|
+
* before agent_end can count it. ② On a DURABLE PARK the verdict is
|
|
203
253
|
* settled at the run tail instead of at agent_end, because the tail first carries the still-
|
|
204
254
|
* undelivered input onto the committed checkpoint's parked-steer queue (the resume delivers it)
|
|
205
255
|
* — what is announced is the remainder that could NOT be carried, so a fully-migrated park says
|
|
@@ -230,10 +280,9 @@ export interface EngineNotice {
|
|
|
230
280
|
* the other question (was the halt CONSUMED), see that seat.
|
|
231
281
|
* In both lanes: one notice per REAL cut (a `now` that found nothing in flight or whose frame
|
|
232
282
|
* already rode the imminent boundary, and a halt with nothing in flight, announce nothing — no
|
|
233
|
-
* false interrupt claims); `runId`
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
* who forced the boundary is the one entitled to see that it landed.
|
|
283
|
+
* false interrupt claims); `runId` is the INVOCATION that was cut; every cut turn counts toward
|
|
284
|
+
* `maxTurns`, so an interrupt storm's cost is bounded and each of its cuts is on the record.
|
|
285
|
+
* Audience `"user"`: the person who forced the boundary is the one entitled to see that it landed.
|
|
237
286
|
* RETIRED here (BREAKING, named): `"task.injection_priority_unimplemented"` — the ladder is
|
|
238
287
|
* implemented, so the unhonored-knob disclosure it carried has no referent; consumers must
|
|
239
288
|
* judge ladder support by VERSION, never by that code's absence.
|
|
@@ -320,9 +369,8 @@ export interface EngineNotice {
|
|
|
320
369
|
* the inputId, so "accepted at park, screened at redelivery" is auditable end to end. Audience
|
|
321
370
|
* `"user"` (the person whose instruction was withheld is the one entitled to re-issue it);
|
|
322
371
|
* `detail: { inputId?, sessionId, runId, taskId? }` — `inputId` is the parked entry's stored
|
|
323
|
-
* correlation key (OPTIONAL: a row parked without one exists), and `runId`
|
|
324
|
-
*
|
|
325
|
-
* one.
|
|
372
|
+
* correlation key (OPTIONAL: a row parked without one exists), and `runId` names the resume leg
|
|
373
|
+
* that withheld it, so a session resumed twice does not report two withholdings as one.
|
|
326
374
|
*
|
|
327
375
|
* - `"memory.session_polluted"` (design/178 §3, #324a; message mode-aware since design/336) —
|
|
328
376
|
* this session's memory crossed into the one-way externally-exposed state (a tool classified
|
|
@@ -20,7 +20,12 @@ export interface FsWriteGatePolicyOptions {
|
|
|
20
20
|
acceptDirs?: readonly string[];
|
|
21
21
|
/** Writes whose canonical target lands inside one of these dirs are ALLOWED and never asked
|
|
22
22
|
* (auto-approved working areas: the scratchpad dir, plan files). Checked before `acceptDirs`
|
|
23
|
-
* (same verdict — the split is intent/observability, exemptions are structural not session-won).
|
|
23
|
+
* (same verdict — the split is intent/observability, exemptions are structural not session-won).
|
|
24
|
+
* An entry that RESOLVES onto `rootPath` — or above it — grants nothing: a domain that holds the
|
|
25
|
+
* root is not a working area but the whole workspace, and a symlinked scratchpad is all it takes.
|
|
26
|
+
* Same rule, same predicate as the prompt's advertised writable directories; say "every write here
|
|
27
|
+
* is pre-approved" with `defaultWrite: "allow"` instead. `acceptDirs` is NOT judged by it (a person
|
|
28
|
+
* may grant the whole root for a session — see the module header). */
|
|
24
29
|
exemptDirs?: readonly string[];
|
|
25
30
|
/** Verdict for a covered write OUTSIDE every domain: `"ask"` (the manual-mode default) routes it
|
|
26
31
|
* to the ask-resolution chain (live onAsk / durable park / headless deny); `"allow"` is the
|
|
@@ -55,17 +60,20 @@ export interface FsWriteGatePolicyOptions {
|
|
|
55
60
|
* deployment's gate options, not in core-global state: core stays lane-agnostic (no device concept
|
|
56
61
|
* here); the deployment that runs guarded-capable envs arms it.
|
|
57
62
|
*
|
|
58
|
-
* WHAT ARMED DOES AND DOES NOT CLOSE (honesty boundary
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* this knob.
|
|
63
|
+
* WHAT ARMED DOES AND DOES NOT CLOSE (honesty boundary): armed guarantees every covered write lands
|
|
64
|
+
* through an ATOMIC precondition-verified backend step, closing the tool's own canonicalize→write
|
|
65
|
+
* window. It does NOT bind THIS GATE's adjudicated canonical key across an approval wait: the tool
|
|
66
|
+
* re-canonicalizes at execute time, so a symlink swapped between this gate's verdict and the tool's
|
|
67
|
+
* execution is re-resolved (then atomically verified against the NEW resolution). Closing that
|
|
68
|
+
* remaining window needs the adjudicated key carried through a trusted approval receipt into tool
|
|
69
|
+
* execution — a separate seat, deliberately not smuggled into this knob.
|
|
66
70
|
*/
|
|
67
71
|
requireGuardedWrite?: boolean;
|
|
68
72
|
}
|
|
73
|
+
/** The coded refusal a gate wired with a root-covering exempt domain throws at CONSTRUCTION — the
|
|
74
|
+
* `config.*` prepare-refusal family (one exported spelling per member, thrown so the task never
|
|
75
|
+
* starts), so a deployment can compare against this constant instead of matching message text. */
|
|
76
|
+
export declare const WRITE_EXEMPT_DIR_COVERS_ROOT_CODE = "config.write_exempt_dir_covers_root";
|
|
69
77
|
/**
|
|
70
78
|
* Build the file-write approval gate policy. Mount via `combinePolicies` alongside the deployment's
|
|
71
79
|
* other layers (sensitive-path deny, session rules, caller policy) — see the module header for the
|
|
@@ -1,12 +1,37 @@
|
|
|
1
|
-
import { canonicalizeTarget, pathFamilyOf, writeTargetPath } from "../tools/fs/safety.js";
|
|
1
|
+
import { canonicalizeTarget, lexicalViewOf, pathFamilyOf, writeTargetPath } from "../tools/fs/safety.js";
|
|
2
2
|
import { isWithin } from "./runner/session-rule-policy.js";
|
|
3
|
+
import { swallowedFenceRoot } from "./runner/advertised-writable-dirs.js";
|
|
3
4
|
import { declaredPathTargetOf, isProtectedWrite } from "./tool-registry.js";
|
|
4
5
|
import { pathTargetBaseOf } from "./effective-path-target.js";
|
|
5
6
|
const ask = (message) => ({ action: "ask", message, decisionReason: "rule" });
|
|
7
|
+
function absoluteRootOf(rootPath, envCwd) {
|
|
8
|
+
return rootPath === undefined ? undefined : lexicalViewOf(rootPath, envCwd);
|
|
9
|
+
}
|
|
10
|
+
export const WRITE_EXEMPT_DIR_COVERS_ROOT_CODE = "config.write_exempt_dir_covers_root";
|
|
11
|
+
function assertExemptDirsDoNotCoverRoot(exemptDirs, rootLexical) {
|
|
12
|
+
if (exemptDirs === undefined || rootLexical === undefined)
|
|
13
|
+
return;
|
|
14
|
+
for (const raw of exemptDirs) {
|
|
15
|
+
if (!raw)
|
|
16
|
+
continue;
|
|
17
|
+
const dirLexical = lexicalViewOf(raw, rootLexical);
|
|
18
|
+
const swallowed = swallowedFenceRoot(dirLexical, [rootLexical]);
|
|
19
|
+
if (swallowed === undefined)
|
|
20
|
+
continue;
|
|
21
|
+
const e = new Error(`createFsWriteGatePolicy: exemptDirs entry "${raw}" resolves to ${dirLexical}, which covers this gate's root ${swallowed} — ` +
|
|
22
|
+
`an exemption that holds the root auto-approves every write under it, which is the whole workspace, so the gate refuses to be built rather than run with write governance silently off. ` +
|
|
23
|
+
`If that IS what this deployment wants, say it with the knob that means it: defaultWrite: "allow", an open write face, or a permission rule.`);
|
|
24
|
+
e.code = WRITE_EXEMPT_DIR_COVERS_ROOT_CODE;
|
|
25
|
+
e.detail = { exemptDir: raw, exemptDirResolved: dirLexical, root: swallowed };
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
6
29
|
export function createFsWriteGatePolicy(opts) {
|
|
7
30
|
const { env, rootPath, defaultWrite } = opts;
|
|
8
31
|
const acceptDirs = opts.acceptDirs && opts.acceptDirs.length > 0 ? opts.acceptDirs : undefined;
|
|
9
32
|
const exemptDirs = opts.exemptDirs && opts.exemptDirs.length > 0 ? opts.exemptDirs : undefined;
|
|
33
|
+
const rootLexical = absoluteRootOf(rootPath, env.cwd);
|
|
34
|
+
assertExemptDirsDoNotCoverRoot(exemptDirs, rootLexical);
|
|
10
35
|
const guardedCapable = env.writeFileGuarded !== undefined;
|
|
11
36
|
const advisory = guardedCapable
|
|
12
37
|
? ""
|
|
@@ -35,12 +60,34 @@ export function createFsWriteGatePolicy(opts) {
|
|
|
35
60
|
if (isWithin(root, canon.key))
|
|
36
61
|
return { action: "allow" };
|
|
37
62
|
}
|
|
38
|
-
|
|
63
|
+
let fenceRootsCanonical;
|
|
64
|
+
let refusedDomain;
|
|
65
|
+
for (const [dirs, structural] of [
|
|
66
|
+
[exemptDirs, true],
|
|
67
|
+
[acceptDirs, false],
|
|
68
|
+
]) {
|
|
39
69
|
if (!dirs)
|
|
40
70
|
continue;
|
|
41
71
|
for (const raw of dirs) {
|
|
42
72
|
const dir = await canonicalizeTarget(env, raw, signal, pathFamilyOf({ root: rootPath ?? env.cwd }), rootPath);
|
|
43
|
-
if (dir.ok
|
|
73
|
+
if (!dir.ok)
|
|
74
|
+
continue;
|
|
75
|
+
if (structural) {
|
|
76
|
+
if (fenceRootsCanonical === undefined) {
|
|
77
|
+
const r = rootPath === undefined ? undefined : await canonicalizeTarget(env, rootPath, signal, pathFamilyOf({ root: rootPath }), undefined);
|
|
78
|
+
fenceRootsCanonical = r === undefined ? [] : r.ok ? [r.key] : "unresolved";
|
|
79
|
+
}
|
|
80
|
+
if (fenceRootsCanonical === "unresolved") {
|
|
81
|
+
refusedDomain ??= `"${raw}" (this task's root ${rootPath} could not be resolved, so a structural domain that may cover it grants nothing)`;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const swallowed = swallowedFenceRoot(dir.key, fenceRootsCanonical);
|
|
85
|
+
if (swallowed !== undefined) {
|
|
86
|
+
refusedDomain ??= `"${raw}" (resolves to ${dir.key}, which contains this task's root ${swallowed})`;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (isWithin(dir.key, canon.key))
|
|
44
91
|
return { action: "allow" };
|
|
45
92
|
}
|
|
46
93
|
}
|
|
@@ -62,7 +109,7 @@ export function createFsWriteGatePolicy(opts) {
|
|
|
62
109
|
}
|
|
63
110
|
if (defaultWrite === "allow")
|
|
64
111
|
return { action: "allow" };
|
|
65
|
-
return ask(`approve write to "${path}"?${advisory}`);
|
|
112
|
+
return ask(`approve write to "${path}"?${advisory}${refusedDomain === undefined ? "" : ` — note: the configured write domain ${refusedDomain} was not honored; a domain that holds the root would exempt every write under it`}`);
|
|
66
113
|
},
|
|
67
114
|
};
|
|
68
115
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { traceHookCrash } from "./runner/gate-exit.js";
|
|
2
|
+
import type { WriteProtectionVerdict } from "./write-protect.js";
|
|
3
|
+
/**
|
|
4
|
+
* design/77 §4: resolve `p`, but REJECT (so the caller catches and fails closed) if it hasn't settled within
|
|
5
|
+
* `ms` OR the `signal` aborts first (the task timed out / was cancelled — its real deadline). A rejection of
|
|
6
|
+
* `p` propagates with its true cause. Both `ms` and `signal` undefined ⇒ no deadline (await `p`) — the
|
|
7
|
+
* helper stays generic; every gate call site supplies a finite `ms` ({@link createGateDeadline}), so the
|
|
8
|
+
* no-deadline arm is unreachable from the gate. Mirrors `tool-policy.ts withTimeout`, but REJECTS on
|
|
9
|
+
* timeout (no bounded await here has a safe fallback VALUE — the gate's only safe action on an unsettled
|
|
10
|
+
* one is its own fail-closed arm). `label` names the wait in the rejection, so the arm that catches it can
|
|
11
|
+
* say which one elapsed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function withGateTimeout<T>(p: Promise<T>, ms: number | undefined, signal?: AbortSignal, label?: string): Promise<T>;
|
|
14
|
+
/**
|
|
15
|
+
* The deadline used when the caller supplies no `approvalTimeoutMs`. The wait must be bounded EVERYWHERE:
|
|
16
|
+
* the engine's own assembly never passes `approvalTimeoutMs`, and its only other bound is the task abort
|
|
17
|
+
* signal, which a task with no walltime limit and no external cancel never fires — so a host driving the
|
|
18
|
+
* public `runToolGate` with neither knob had NO bound at all and one hung probe froze the call forever. A
|
|
19
|
+
* probe is an args inspection and a judgment is a path resolution, not an approval wait: 30s is generous
|
|
20
|
+
* for any legitimate one, and an elapse simply takes the caller's own fail-closed arm.
|
|
21
|
+
*/
|
|
22
|
+
export declare const DEFAULT_GATE_AWAIT_TIMEOUT_MS = 30000;
|
|
23
|
+
/**
|
|
24
|
+
* The gate's ONE deadline seat, resolved LAZILY and at most once per gate pass: a pass that makes no
|
|
25
|
+
* bounded await must not sound about a seat it never read. A garbage value (NaN/Infinity/negative, or
|
|
26
|
+
* above setTimeout's 2^31-1 ceiling, which it would silently clamp to ~1ms — an instant fail-closed the
|
|
27
|
+
* deployment never asked for) is refused LOUDLY to the default rather than silently reinterpreted
|
|
28
|
+
* (bad-value loudness discipline; the same ceiling the approve-wait seat refuses).
|
|
29
|
+
*/
|
|
30
|
+
export declare function createGateDeadline(input: {
|
|
31
|
+
approvalTimeoutMs?: number;
|
|
32
|
+
}, notifier: Parameters<typeof traceHookCrash>[2]): () => number;
|
|
33
|
+
/**
|
|
34
|
+
* The write-protection judge's wait, at both of its call sites. An elapsed or aborted judgment is the
|
|
35
|
+
* judge's own `unresolvable` verdict — which the fold denies and the approved-edit arm denies — because a
|
|
36
|
+
* wait that did not finish IS a target nobody could resolve, and an unjudged target is never vouched.
|
|
37
|
+
*/
|
|
38
|
+
export declare function awaitWriteProtectionJudgment(judged: Promise<WriteProtectionVerdict>, ms: number, signal?: AbortSignal): Promise<WriteProtectionVerdict>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { traceHookCrash } from "./runner/gate-exit.js";
|
|
2
|
+
export function withGateTimeout(p, ms, signal, label = "reversibilityProbe") {
|
|
3
|
+
if (ms === undefined && signal === undefined)
|
|
4
|
+
return p;
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
let settled = false;
|
|
7
|
+
const done = (fn, v) => {
|
|
8
|
+
if (settled)
|
|
9
|
+
return;
|
|
10
|
+
settled = true;
|
|
11
|
+
if (timer !== undefined)
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
signal?.removeEventListener("abort", onAbort);
|
|
14
|
+
fn(v);
|
|
15
|
+
};
|
|
16
|
+
const onAbort = () => done(reject, new Error(`${label} aborted (task ended)`));
|
|
17
|
+
const timer = ms !== undefined
|
|
18
|
+
? setTimeout(() => done(reject, new Error(`${label} timed out after ${ms}ms`)), ms)
|
|
19
|
+
: undefined;
|
|
20
|
+
p.then((v) => done(resolve, v), (err) => done(reject, err));
|
|
21
|
+
if (signal?.aborted) {
|
|
22
|
+
onAbort();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
signal?.addEventListener("abort", onAbort);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export const DEFAULT_GATE_AWAIT_TIMEOUT_MS = 30_000;
|
|
29
|
+
export function createGateDeadline(input, notifier) {
|
|
30
|
+
let resolved;
|
|
31
|
+
return () => {
|
|
32
|
+
if (resolved !== undefined)
|
|
33
|
+
return resolved;
|
|
34
|
+
const supplied = input.approvalTimeoutMs;
|
|
35
|
+
if (supplied === undefined)
|
|
36
|
+
resolved = DEFAULT_GATE_AWAIT_TIMEOUT_MS;
|
|
37
|
+
else if (Number.isFinite(supplied) && supplied >= 0 && supplied <= 2_147_483_647)
|
|
38
|
+
resolved = supplied;
|
|
39
|
+
else {
|
|
40
|
+
resolved = DEFAULT_GATE_AWAIT_TIMEOUT_MS;
|
|
41
|
+
traceHookCrash(input, new Error(`approvalTimeoutMs must be a non-negative finite number no greater than 2147483647 (got ${String(supplied)}) — the gate's bounded awaits fall back to the ${DEFAULT_GATE_AWAIT_TIMEOUT_MS}ms default`), notifier);
|
|
42
|
+
}
|
|
43
|
+
return resolved;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export async function awaitWriteProtectionJudgment(judged, ms, signal) {
|
|
47
|
+
try {
|
|
48
|
+
return await withGateTimeout(judged, ms, signal, "writeProtectionJudge");
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return { kind: "unresolvable", cause: err instanceof Error ? err.message : String(err), unresolvedSymlink: false };
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/core/gate-fold.js
CHANGED
|
@@ -1,35 +1,12 @@
|
|
|
1
1
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
2
2
|
import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
|
|
3
|
+
import { normalizeReadRootCandidate } from "./read-root-candidate.js";
|
|
3
4
|
import { traceHookCrash } from "./runner/gate-exit.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
return p;
|
|
7
|
-
return new Promise((resolve, reject) => {
|
|
8
|
-
let settled = false;
|
|
9
|
-
const done = (fn, v) => {
|
|
10
|
-
if (settled)
|
|
11
|
-
return;
|
|
12
|
-
settled = true;
|
|
13
|
-
if (timer !== undefined)
|
|
14
|
-
clearTimeout(timer);
|
|
15
|
-
signal?.removeEventListener("abort", onAbort);
|
|
16
|
-
fn(v);
|
|
17
|
-
};
|
|
18
|
-
const onAbort = () => done(reject, new Error("reversibilityProbe aborted (task ended)"));
|
|
19
|
-
const timer = ms !== undefined
|
|
20
|
-
? setTimeout(() => done(reject, new Error(`reversibilityProbe timed out after ${ms}ms`)), ms)
|
|
21
|
-
: undefined;
|
|
22
|
-
if (signal?.aborted) {
|
|
23
|
-
onAbort();
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
signal?.addEventListener("abort", onAbort);
|
|
27
|
-
p.then((v) => done(resolve, v), (err) => done(reject, err));
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
|
|
5
|
+
import { writeProtectionTightenDecision } from "./write-protect.js";
|
|
6
|
+
import { createGateDeadline, awaitWriteProtectionJudgment, withGateTimeout } from "./gate-bounded-await.js";
|
|
31
7
|
export async function runGateFold(pass) {
|
|
32
8
|
const { input, toolName, toolCallId, callSignal, ledger, notifier, adjudicate } = pass;
|
|
9
|
+
const boundedAwaitDeadlineMs = createGateDeadline(input, notifier);
|
|
33
10
|
pass.req = { toolName, args: pass.currentInput, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
|
|
34
11
|
pass.decision = adjudicate ? await (callSignal !== undefined ? adjudicate(pass.req, callSignal) : adjudicate(pass.req)) : { action: "allow" };
|
|
35
12
|
{
|
|
@@ -38,6 +15,9 @@ export async function runGateFold(pass) {
|
|
|
38
15
|
pass.deniedBy = refusedBy;
|
|
39
16
|
if (refusedBy !== undefined)
|
|
40
17
|
pass.denyCause = ledger.causeOf(pass.decision);
|
|
18
|
+
const attributed = ledger.classifierRoundOf(pass.decision);
|
|
19
|
+
if (attributed !== undefined)
|
|
20
|
+
pass.classifierRound = attributed;
|
|
41
21
|
}
|
|
42
22
|
if ((pass.decision.action === "allow" || pass.decision.action === "ask") && pass.decision.updatedInput !== undefined) {
|
|
43
23
|
pass.policyRewrite = pass.decision.updatedInput;
|
|
@@ -80,23 +60,13 @@ export async function runGateFold(pass) {
|
|
|
80
60
|
let probeReason;
|
|
81
61
|
let probeCause;
|
|
82
62
|
let probeMandated;
|
|
63
|
+
let probeReadRootCandidate;
|
|
83
64
|
if (input.irreversibility === "maybe" && input.reversibilityProbe) {
|
|
84
65
|
let reversible = false;
|
|
85
66
|
const probeArgs = pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput;
|
|
86
|
-
const
|
|
87
|
-
let probeTimeoutMs;
|
|
88
|
-
if (suppliedProbeMs === undefined) {
|
|
89
|
-
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
|
|
90
|
-
}
|
|
91
|
-
else if (Number.isFinite(suppliedProbeMs) && suppliedProbeMs >= 0 && suppliedProbeMs <= 2_147_483_647) {
|
|
92
|
-
probeTimeoutMs = suppliedProbeMs;
|
|
93
|
-
}
|
|
94
|
-
else {
|
|
95
|
-
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
|
|
96
|
-
traceHookCrash(input, new Error(`approvalTimeoutMs must be a non-negative finite number no greater than 2147483647 (got ${String(suppliedProbeMs)}) — the reversibilityProbe deadline falls back to the ${DEFAULT_PROBE_TIMEOUT_MS}ms default`), notifier);
|
|
97
|
-
}
|
|
67
|
+
const probeTimeoutMs = boundedAwaitDeadlineMs();
|
|
98
68
|
try {
|
|
99
|
-
const verdict = await
|
|
69
|
+
const verdict = await withGateTimeout(Promise.resolve(input.reversibilityProbe(probeArgs)), probeTimeoutMs, input.abortSignal);
|
|
100
70
|
reversible = verdict?.reversible === true;
|
|
101
71
|
if (!reversible) {
|
|
102
72
|
const raw = verdict?.reason;
|
|
@@ -105,6 +75,7 @@ export async function runGateFold(pass) {
|
|
|
105
75
|
probeCause = normalizeProbeCause(verdict?.cause);
|
|
106
76
|
if (verdict?.mandated === true)
|
|
107
77
|
probeMandated = true;
|
|
78
|
+
probeReadRootCandidate = normalizeReadRootCandidate(verdict?.readRootCandidate);
|
|
108
79
|
}
|
|
109
80
|
}
|
|
110
81
|
catch (err) {
|
|
@@ -123,6 +94,7 @@ export async function runGateFold(pass) {
|
|
|
123
94
|
...(probeReason !== undefined ? { probeReason } : {}),
|
|
124
95
|
...(probeCause !== undefined ? { probeCause } : {}),
|
|
125
96
|
...(probeMandated === true ? { probeMandated: true } : {}),
|
|
97
|
+
...(probeReadRootCandidate !== undefined ? { probeReadRootCandidate } : {}),
|
|
126
98
|
};
|
|
127
99
|
pass.tightenedBy = input.shellGated === true ? "shell_gate" : "safety";
|
|
128
100
|
}
|
|
@@ -139,14 +111,14 @@ export async function runGateFold(pass) {
|
|
|
139
111
|
pass.tightenedBy = "safety";
|
|
140
112
|
}
|
|
141
113
|
if (pass.decision.action === "allow" && input.writeProtectionCheck !== undefined) {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
114
|
+
const judged = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, pass.req.face, input.trackedCwd?.(), pass.callSignal ?? input.abortSignal);
|
|
115
|
+
const decision = judged === null ? undefined : writeProtectionTightenDecision(toolName, await awaitWriteProtectionJudgment(judged, boundedAwaitDeadlineMs(), pass.callSignal ?? input.abortSignal));
|
|
116
|
+
if (decision !== undefined) {
|
|
117
|
+
pass.decision = decision;
|
|
118
|
+
if (decision.action === "ask")
|
|
119
|
+
pass.tightenedBy = "safety";
|
|
120
|
+
else
|
|
121
|
+
pass.deniedBy = "write_protection";
|
|
150
122
|
}
|
|
151
123
|
}
|
|
152
124
|
return await pass.afterFold(pass);
|