@sema-agent/core 5.26.0 → 5.28.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 +113 -0
- package/dist/agents/agent-transcript-tool.d.ts +5 -2
- package/dist/agents/agent-transcript-tool.js +2 -1
- package/dist/agents/send-message-tool.d.ts +4 -1
- package/dist/agents/subagent.d.ts +5 -2
- package/dist/core/checkpoint-store.d.ts +7 -2
- package/dist/core/hooks.d.ts +61 -4
- package/dist/core/hooks.js +37 -15
- package/dist/core/memory-engine/engine.d.ts +8 -5
- package/dist/core/memory-engine/engine.js +18 -6
- package/dist/core/memory-engine/file-backend.d.ts +144 -4
- package/dist/core/memory-engine/file-backend.js +304 -36
- package/dist/core/memory-engine/layout.d.ts +31 -2
- package/dist/core/memory-engine/layout.js +132 -8
- package/dist/core/memory-engine/types.d.ts +9 -1
- package/dist/core/memory-vector.d.ts +6 -1
- package/dist/core/memory-vector.js +14 -4
- package/dist/core/memory.js +1 -6
- package/dist/core/permission-rule-consent.d.ts +82 -8
- package/dist/core/permission-rule-consent.js +92 -1
- package/dist/core/permission-rule-model.d.ts +87 -6
- package/dist/core/permission-rule-model.js +79 -0
- package/dist/core/permission-rule-org.d.ts +22 -3
- package/dist/core/permission-rule-org.js +67 -20
- package/dist/core/permission-rule-store.js +2 -2
- package/dist/core/permission-rule-sync.d.ts +15 -1
- package/dist/core/permission-rule-sync.js +89 -47
- package/dist/core/runner/prepare-memory.js +14 -9
- package/dist/core/runner/prepare-task.d.ts +9 -3
- package/dist/core/runner/prepare-task.js +37 -11
- package/dist/core/runner/runtask.d.ts +8 -1
- package/dist/core/runner/runtask.js +8 -1
- package/dist/core/task-registry-agent.d.ts +13 -3
- package/dist/core/task-registry-agent.js +51 -21
- package/dist/core/task-registry-monitor.js +1 -1
- package/dist/core/task-registry-shared.d.ts +9 -0
- package/dist/core/task-registry.d.ts +6 -3
- package/dist/core/tool-policy.d.ts +44 -4
- package/dist/core/tool-policy.js +37 -3
- package/dist/core/tool-result-store.d.ts +108 -7
- package/dist/core/tool-result-store.js +95 -15
- package/dist/core/types.d.ts +115 -17
- package/dist/core/types.js +30 -1
- package/dist/engine/loop/types.d.ts +10 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/orchestration/run-workflow-tool.d.ts +5 -3
- package/dist/orchestration/workflow.d.ts +9 -6
- package/dist/stores/file/checkpoint-store.d.ts +2 -1
- package/dist/stores/file/index.d.ts +1 -1
- package/dist/stores/file/tool-result-store.d.ts +41 -1
- package/dist/stores/file/tool-result-store.js +107 -19
- package/dist/tools/fs/fs-bash.d.ts +7 -0
- package/dist/tools/fs/fs-shared.d.ts +5 -0
- package/dist/tools/fs/fs-shared.js +11 -7
- package/dist/tools/fs/index.d.ts +6 -0
- package/dist/tools/fs/index.js +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,118 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.28.0 — 2026-08-12
|
|
4
|
+
|
|
5
|
+
No API-BREAKING changes (exports grow only). Narrowings are tighten-direction and named below.
|
|
6
|
+
|
|
7
|
+
### Narrowed (behavior, ruled)
|
|
8
|
+
|
|
9
|
+
- **A hook-promoted ask's provenance is stamped, never read back.** Every consumption point keying
|
|
10
|
+
on `decisionReason === "hook"` now reads an engine-stamped value: the fold reconstructs the
|
|
11
|
+
promoted ask from an explicit member whitelist and stamps `"hook"` unconditionally, so a hook
|
|
12
|
+
self-declaring any other word (or extra members like `persistedRuleShadowed`) cannot change how
|
|
13
|
+
the persisted-rule lane, the rule-suggestion factory, or the inherited-frame reuse treat the ask.
|
|
14
|
+
- **A `decisionReason` outside the closed set is refused at the consumption screen.** A
|
|
15
|
+
deployment-authored `ToolPolicy.check` returning an unrecognized word (or routing the field
|
|
16
|
+
through a getter/prototype) is denied fail-closed with the closed set named; plain decisions pass
|
|
17
|
+
by reference. The HOOK seam is exempt by design (the field is a documented non-input there — see
|
|
18
|
+
the stamp above).
|
|
19
|
+
- **The served org snapshot meets the same validator as a fetched one.** A persisted last-known-good
|
|
20
|
+
snapshot carrying a rule the current validator refuses now fails closed during a provider outage
|
|
21
|
+
instead of serving partial policy.
|
|
22
|
+
- **Legacy rule text the current validator refuses no longer stalls sync.** A shape-whole tombstone
|
|
23
|
+
whose TEXT is refused gets the new closed-set reason `invalid_rule_text` — disclosed, quarantined,
|
|
24
|
+
and no longer withholding the whole inbound-adds round (safe: tombstones match byte-identical
|
|
25
|
+
text only, and an add with that text is refused by the same predicate).
|
|
26
|
+
- **Case-colliding scope directories are refused loudly on case-insensitive filesystems.** Two
|
|
27
|
+
scopes differing only by case no longer silently share one physical directory (macOS/Windows);
|
|
28
|
+
collision detection compares folded identity (dev:ino-keyed), with lazy real probing instead of
|
|
29
|
+
platform guessing.
|
|
30
|
+
- **Spill provenance is session-only, and a failing spill degrades instead of poisoning.** The agent
|
|
31
|
+
spill write points stamp `{sessionId}` (matching the offload/budget/compaction form); a failed or
|
|
32
|
+
conflicting spill put defers to a sibling-proven ref or reports honestly — a legacy-shape row can
|
|
33
|
+
no longer make an agent result permanently unreadable (guarded put + generation guard + coherent
|
|
34
|
+
poll snapshot).
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `onNotice` structured notice seat (first two families: bash timeout, MATERIALIZE) — absent keeps
|
|
39
|
+
the `console.warn` text verbatim; a present non-function seat is announced once and falls back
|
|
40
|
+
loudly instead of silencing both channels.
|
|
41
|
+
- `tool_end.errorCode` now reads both discriminator spellings — `details.code` first (string), then
|
|
42
|
+
`details.errorKind` — so loop-thrown error frames are classifiable; `code` wins when both exist.
|
|
43
|
+
- `ToolResultStore.deleteBySession?` (optional member; File + in-memory implementations, not on the
|
|
44
|
+
scoped wrapper, not in the published contract kit) — returns `{ deleted, unattributable }`;
|
|
45
|
+
selection keys on recorded provenance, unowned rows are counted and never deleted. A put refused
|
|
46
|
+
over an orphan sidecar un-publishes its own content (pre-call state restored), and a damaged
|
|
47
|
+
re-published survivor is counted unattributable.
|
|
48
|
+
- Adopt-on-read ledger writes are serialized behind the existing cross-process txn mutex (lock-free
|
|
49
|
+
probe fast path; divergence acquires the mutex, reloads the ledger from disk, and re-verifies
|
|
50
|
+
lock ownership at recovery, side-effect and commit points) — cross-process lost-update closed.
|
|
51
|
+
- `runTaskStream` refuses an out-of-shape `resume` synchronously (a resume without `outcome` used to
|
|
52
|
+
hang `stream.result()` forever).
|
|
53
|
+
- `docs/INTEGRATION-CORE.md` — the core→embedder contract document (event-frame per-key obligations,
|
|
54
|
+
code-vocabulary pointers, TaskResult matrix, resume/decide shapes, store optional-member table);
|
|
55
|
+
maintained per L3 release.
|
|
56
|
+
- Rule-suggestion admission honesty (#174 adjacency): the suggestion factory keys on the stamped
|
|
57
|
+
provenance.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- Control-character gate: five bench probe arms now genuinely reach their claimed product paths;
|
|
62
|
+
the published B2 index-truncation curve was corrected to `[0, 0, 0.005, 0.602]` (the old counting
|
|
63
|
+
masked a real N200 loss).
|
|
64
|
+
- `TaskResult.retryAfterMs` and `tool_end.errorCode` JSDoc corrected to match code (two accepted
|
|
65
|
+
codes; the errorKind fallback).
|
|
66
|
+
|
|
67
|
+
## 5.27.0 — 2026-08-11
|
|
68
|
+
|
|
69
|
+
No API-BREAKING changes (exports grow only). Narrowings are tighten-direction and named below.
|
|
70
|
+
|
|
71
|
+
### Narrowed (behavior, ruled)
|
|
72
|
+
|
|
73
|
+
- **A declared-unavailable session reads the committed memory view.** Under
|
|
74
|
+
`memoryPersistenceCapable: false`, materialize/search/harvest serve the committed account
|
|
75
|
+
(ledger + shadow): disk divergence with no transaction backing is neither adopted nor served —
|
|
76
|
+
it stays on disk, is loudly reported (`restricted_divergence`, a new `HarvestRejectionCode`
|
|
77
|
+
member — consumers pinning that enum as a closed set must add the row), and a later unrestricted
|
|
78
|
+
session adopts it through the normal gates. Legitimate flows are untouched: user hand-edits and
|
|
79
|
+
git-pull drops keep working (deferred, not destroyed), a concurrent writable session's committed
|
|
80
|
+
changes pass as transaction-backed, and **a writeScope-null plane (org layers default read-only;
|
|
81
|
+
the dual-root non-write plane) keeps its ordinary adopt-on-read semantics** — the restriction is
|
|
82
|
+
the SESSION's declaration, never the plane's structure.
|
|
83
|
+
- **Rule suggestions appear only where a persisted rule could actually work.** Mandated
|
|
84
|
+
(`shellGate:"always"` / tool marks / org / requiresRealApproval), shadowed, hook-originated,
|
|
85
|
+
inherited-unresolved, ancestor-resolved (a live parent approver), and anonymous-task (no
|
|
86
|
+
principal, no local-owner declaration) asks no longer carry `ruleSuggestions` — a card whose rule
|
|
87
|
+
could never silence it stops offering to mint one. Classifier-tier and ordinary policy asks keep
|
|
88
|
+
their suggestions.
|
|
89
|
+
- **Known residual (registered)**: bytes refused by a restricted session remain on disk and are
|
|
90
|
+
adoptable by a later unrestricted session — the alternative (destroying bytes indistinguishable
|
|
91
|
+
from user edits) is ruled out; the boundary is "the restricted session is never polluted and
|
|
92
|
+
tampering is always visible", not "the bytes can never enter".
|
|
93
|
+
|
|
94
|
+
### Added
|
|
95
|
+
|
|
96
|
+
- **Prefix rule candidates**: the suggestion engine mints a reviewed PREFIX option beside the exact
|
|
97
|
+
one for lexicon-listed command bodies (`git push origin main` → optional `Bash(git push:*)`),
|
|
98
|
+
longest word-boundary hit, no fallback; candidate arrays grow from ≤1 to ≤2 with `exact` fixed at
|
|
99
|
+
index 0 (a documented contract across `AskRequest.ruleSuggestions`, checkpoint park rows, and the
|
|
100
|
+
approval card record). Consumers reading `[0]` unconditionally only miss the new option.
|
|
101
|
+
- **CJK lexical retrieval**: `termSet` now mints CJK character bigrams and unigrams beside
|
|
102
|
+
alphanumeric runs — a pure-Chinese memory entry was previously INVISIBLE to the File backend's
|
|
103
|
+
lexical rung (empty term set); the class is written in explicit escapes (a literal compat
|
|
104
|
+
ideograph NFC-folds to its unified twin, silently widening the range).
|
|
105
|
+
- **`toolResultContentSegment` exported** from the package root (the digest segment the engine's
|
|
106
|
+
own mints carry — consumers derive it instead of inventing a placeholder).
|
|
107
|
+
- `MemoryEngine.materialize` accepts an optional `adoptionRestricted` verdict;
|
|
108
|
+
`MemorySessionHandle.adoptionRestricted` reports it (both additive).
|
|
109
|
+
|
|
110
|
+
### Fixed
|
|
111
|
+
|
|
112
|
+
- Three loaded-suite timing margins widened without losing discrimination; the defectscan
|
|
113
|
+
guardrail audit skips a concurrently-vanished temp dir instead of throwing; 24 stale doc/JSDoc
|
|
114
|
+
claims restated to actual behavior (bidirectional sweep + rescan trio).
|
|
115
|
+
|
|
3
116
|
## 5.26.0 — 2026-08-11
|
|
4
117
|
|
|
5
118
|
No API-BREAKING changes (exports grow only; new members optional/additive). Several
|
|
@@ -37,8 +37,11 @@ export interface AgentTranscriptToolOptions {
|
|
|
37
37
|
* into a child's tail when the residual isn't enough.
|
|
38
38
|
*
|
|
39
39
|
* Safety: reads are bounded to the parent's OWN lineage — the same non-leaking registry access scope as
|
|
40
|
-
* SendMessage/TaskOutput (an unknown or out-of-scope id reads identically to "not found").
|
|
41
|
-
*
|
|
40
|
+
* SendMessage/TaskOutput (an unknown or out-of-scope id reads identically to "not found"). Two read
|
|
41
|
+
* paths keep a child's session reachable: the retain ledger (a RETAINED child), and the durable
|
|
42
|
+
* agent-store fallback (`agentStore` wired + accessible row + preserved `sessionId` — readable even
|
|
43
|
+
* with retain off/expired/evicted). Only when BOTH miss does the call return the honest
|
|
44
|
+
* "not retained" / "not found".
|
|
42
45
|
*/
|
|
43
46
|
export declare function createAgentTranscriptTool(opts: AgentTranscriptToolOptions): import("../internal/harness-types.js").AgentTool<Type.TObject<{
|
|
44
47
|
id: Type.TString;
|
|
@@ -16,7 +16,8 @@ export function createAgentTranscriptTool(opts) {
|
|
|
16
16
|
description: `Read the recent tool steps of a background agent you launched (task_id 'a…' from the Agent tool with ` +
|
|
17
17
|
`run_in_background), so you can see precisely where it got to — useful before SendMessage-ing a follow-up, ` +
|
|
18
18
|
`or to inspect a stopped agent's tail. Returns the last N steps (tool, target, outcome); it does not re-run ` +
|
|
19
|
-
`the agent.
|
|
19
|
+
`the agent. Works for agents this run retained (retainSubagentSessions) and for agents whose durable ` +
|
|
20
|
+
`record preserved their transcript session; when neither holds you get an honest error.`,
|
|
20
21
|
parameters: Type.Object({
|
|
21
22
|
id: Type.String({ description: "The background agent's task_id (a…) returned by the Agent tool." }),
|
|
22
23
|
lastN: Type.Optional(Type.Integer({ minimum: 1, maximum: AGENT_TRANSCRIPT_MAX_N, description: `How many recent steps to return (default ${AGENT_TRANSCRIPT_DEFAULT_N}).` })),
|
|
@@ -19,7 +19,10 @@ export interface SendMessageToolOptions {
|
|
|
19
19
|
owner?: string;
|
|
20
20
|
scope?: string;
|
|
21
21
|
sessionId?: string;
|
|
22
|
-
/**
|
|
22
|
+
/** Completion-notify sink for the resumed run. The Runner mount wires the SENDING run's own
|
|
23
|
+
* notification injector here (its runtask-wrapped `injectTaskNotification`), so the completion
|
|
24
|
+
* notice lands in the sender's live injection queue at a turn boundary; a direct mount may wire a
|
|
25
|
+
* deployment-level background-notify sink instead. Absent ⇒ completion is silent. */
|
|
23
26
|
notify?: (n: TaskNotificationPayload, opts?: {
|
|
24
27
|
priority?: "now" | "next" | "later";
|
|
25
28
|
}) => void;
|
|
@@ -361,8 +361,11 @@ export interface SubagentSteerHandle {
|
|
|
361
361
|
* before any await; rolled back on pre-launch failure) → session exists AND non-empty (r1-m1).
|
|
362
362
|
* Then: fenced revive prompt (unpredictable marker) →
|
|
363
363
|
* `runTaskStream` on the FROZEN spawn snapshot + `requireExistingSession` (spec is tighten-only: the caller
|
|
364
|
-
* contributes ONLY `content`) → ledger `activeRun` bookkeeping → completion
|
|
365
|
-
*
|
|
364
|
+
* contributes ONLY `content`) → ledger `activeRun` bookkeeping → completion announced through
|
|
365
|
+
* `deps.notify` (no sink ⇒ silent). Which lane
|
|
366
|
+
* that is depends on the mount: the background Agent lane wires the deployment-level background-notify
|
|
367
|
+
* sink, while the SendMessage mount wires the SENDING run's own notification injector — there the
|
|
368
|
+
* completion notice lands in the sender's live injection queue at its next turn boundary.
|
|
366
369
|
*/
|
|
367
370
|
export declare function createSubagentResume(deps: {
|
|
368
371
|
ledger: SubagentRetainLedger | undefined;
|
|
@@ -574,8 +574,13 @@ export type PendingAction = {
|
|
|
574
574
|
/** design/179 §4 (additive) — the persistable allow-rule forms this parked call could be covered by,
|
|
575
575
|
* minted from the SAME post-hook args the row executes. Advisory display metadata for the approval
|
|
576
576
|
* inbox's "stop asking me this" option; never read by the resume path, and never a rule by itself
|
|
577
|
-
* (minting one goes through the approval-record protocol). Absent when no rule lane is armed
|
|
578
|
-
* command is one the lane cannot speak for
|
|
577
|
+
* (minting one goes through the approval-record protocol). Absent when no rule lane is armed, the
|
|
578
|
+
* command is one the lane cannot speak for, or the parked ask is one a persisted rule could not
|
|
579
|
+
* clear (mandated marks / a real-approval demand / a shadowed matching rule).
|
|
580
|
+
* CONTRACT (same as the synchronous `AskRequest.ruleSuggestions`) — array order is display order,
|
|
581
|
+
* narrowest first: the EXACT form is always index 0, a broader reviewed PREFIX form (at most one)
|
|
582
|
+
* follows; basis ≤ 2, and selection/ticket indices are keyed against this order. A row persisted
|
|
583
|
+
* before the prefix arm existed carries ≤ 1 entry and stays valid under the same reading. */
|
|
579
584
|
ruleSuggestions?: readonly import("./permission-rule-model.js").RuleSuggestion[];
|
|
580
585
|
/**
|
|
581
586
|
* design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
|
|
2
2
|
import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
|
|
3
|
-
import type { PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
|
|
3
|
+
import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
|
|
4
4
|
import { type AskClass } from "./ask-class.js";
|
|
5
5
|
/**
|
|
6
|
-
* In-process hook seam (design/37) — a
|
|
7
|
-
*
|
|
6
|
+
* In-process hook seam (design/37) — a provider-agnostic interception layer modeled on CC's hooks,
|
|
7
|
+
* kept process-internal (no shell/HTTP executors, no settings files). The original tool-call trio:
|
|
8
8
|
*
|
|
9
9
|
* - {@link Hooks.preToolUse} runs before a tool executes. It may **rewrite** the args (an `allow` with
|
|
10
10
|
* `updatedInput`), **restrict** the call (`deny`/`ask`), and/or inject `additionalContext`. It is a
|
|
@@ -15,6 +15,13 @@ import { type AskClass } from "./ask-class.js";
|
|
|
15
15
|
* - {@link Hooks.userPromptSubmit} runs before the objective becomes a user message. It may `block`
|
|
16
16
|
* submission (the task fails with a model-readable reason) or inject `additionalContext` ahead of it.
|
|
17
17
|
*
|
|
18
|
+
* The seam has since grown well past the trio — each later member carries its own contract below:
|
|
19
|
+
* {@link Hooks.stop} / {@link Hooks.stopFailure} (run-end pushback / API-error terminal observer),
|
|
20
|
+
* {@link Hooks.postToolUseFailure} and {@link Hooks.postToolBatch} (failure + batch-boundary taps),
|
|
21
|
+
* {@link Hooks.preCompact} / {@link Hooks.postCompact} (compaction gate + observer),
|
|
22
|
+
* {@link Hooks.permissionDenied} (deny observer), and the {@link Hooks.preToolUseObservational}
|
|
23
|
+
* declaration flag.
|
|
24
|
+
*
|
|
18
25
|
* All feedback the model should see (a block reason, injected context) is wrapped as a
|
|
19
26
|
* `<system-reminder>` via {@link formatHookFeedback} so the model can read and self-correct.
|
|
20
27
|
*/
|
|
@@ -410,6 +417,28 @@ export interface HookToolOutput {
|
|
|
410
417
|
/**
|
|
411
418
|
* A PreToolUse hook result: a {@link PermissionResult} (so a hook can `deny`/`ask`, or `allow` with an
|
|
412
419
|
* `updatedInput` rewrite) plus optional `additionalContext` injected into the eventual tool result.
|
|
420
|
+
*
|
|
421
|
+
* **The provenance members are not hook-settable inputs** (#174). `decisionReason` is the ENGINE's account
|
|
422
|
+
* of which layer produced a verdict, and `persistedRuleShadowed` is the gate's own #144 disclosure that a
|
|
423
|
+
* stored rule matched without clearing the ask. The gate stamps both itself — a `decisionReason` on a hook
|
|
424
|
+
* result is DISCARDED (a hook ask always reads `"hook"` downstream), and a `persistedRuleShadowed` is
|
|
425
|
+
* dropped rather than rendered on an approval card as a rule no lane matched.
|
|
426
|
+
*
|
|
427
|
+
* Which of a hook's OTHER members reach the surviving decision depends on which fold arm ran, so read
|
|
428
|
+
* the arm, not a single list: an ask promoted from a policy `allow` is rebuilt from the hook's `message`,
|
|
429
|
+
* `requiresRealApproval` and `updatedInput`, while a hook ask folded BESIDE a policy ask contributes its
|
|
430
|
+
* message and its mandate to the policy's decision and leaves the policy's own rewrite in place (the
|
|
431
|
+
* hook's rewrite still reaches execution — through the gate's captured rewrite, not through the decision
|
|
432
|
+
* object). Both arms are at the `hookAsk` fold in {@link runToolGate}.
|
|
433
|
+
*
|
|
434
|
+
* `additionalContext` is on neither list and never was consumed as a decision member: it rides its own
|
|
435
|
+
* channel — collected into the gate's `preToolContext` as soon as the hook answers, whichever verdict it
|
|
436
|
+
* returned, and drained onto the tool result by the runner. DELIVERY is narrower than collection: the
|
|
437
|
+
* runner stashes the context only for a call that will EXECUTE, because a blocked or suspended call never
|
|
438
|
+
* reaches the result step that drains it and carries its own model-facing reason instead. The context
|
|
439
|
+
* formerly APPEARED on the promoted ask object as an undeclared property, because that fold spread the
|
|
440
|
+
* whole hook result; the concurrent arm never carried it, so the two arms disagreed on a member neither
|
|
441
|
+
* of them reads. Nothing in the engine reads it off a decision.
|
|
413
442
|
*/
|
|
414
443
|
export type PreToolUseResult = PermissionResult & {
|
|
415
444
|
additionalContext?: string;
|
|
@@ -611,7 +640,12 @@ export interface ToolGateInput {
|
|
|
611
640
|
realApproval?: import("./checkpoint-store.js").RealApprovalGateBit,
|
|
612
641
|
/** #144: the matched-but-outranked persisted rule (the surviving ask's `persistedRuleShadowed`)
|
|
613
642
|
* — threaded so the park mint's risk descriptor carries the disclosure on the durable route. */
|
|
614
|
-
shadowedRule?: string
|
|
643
|
+
shadowedRule?: string,
|
|
644
|
+
/** The surviving ask's `decisionReason` — threaded so the park mint's rule-suggestion door can
|
|
645
|
+
* read the ask's origin on the durable route exactly as the synchronous mints read it off the
|
|
646
|
+
* decision (a hook-raised ask is one no persisted rule may clear, so its parked card must not
|
|
647
|
+
* offer to mint one). */
|
|
648
|
+
askDecisionReason?: DecisionReason) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
|
|
615
649
|
/**
|
|
616
650
|
* design/174 — route a policy `ask` on the reserved question tool to this run's CONTENT-ask channel
|
|
617
651
|
* before it can become a park or a refusal. Called in the `ask` branch with the FINAL post-hook,
|
|
@@ -871,6 +905,29 @@ export interface ToolGateInput {
|
|
|
871
905
|
* reach the same lane from inside a descendant's fold.
|
|
872
906
|
*/
|
|
873
907
|
export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void): ToolPolicy;
|
|
908
|
+
/**
|
|
909
|
+
* The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
|
|
910
|
+
* the single source for "could a persisted allow rule clear this ask?". Allow rules silence the
|
|
911
|
+
* classifier's questions, never a mandated one, and this predicate is the mandated-family half of
|
|
912
|
+
* that boundary (the real-approval/governance half rides the decision's own `requiresRealApproval`
|
|
913
|
+
* bit, which the org layer stamps):
|
|
914
|
+
* · `egress` — the tool's own external-write mark, judged FIRST: it is the tool's declaration even
|
|
915
|
+
* when the coarse doctrine also installed a shell tier on the same seat;
|
|
916
|
+
* · `shellGated` + tier `"always"` — the operator's per-call confirmation doctrine
|
|
917
|
+
* (`"operator_always"`); the classify doctrine installs `"maybe"`, and THOSE asks stay the rule
|
|
918
|
+
* lane's home turf (`undefined`);
|
|
919
|
+
* · a tool's OWN `"always"`/`"maybe"` irreversibility tier without the doctrine (`"tool_marks"`).
|
|
920
|
+
*
|
|
921
|
+
* Two consumers, one derivation: the gate's silencing arm (a matching rule is disclosed as shadowed
|
|
922
|
+
* instead of clearing the ask) and the runner's suggestion factory (a mandated ask offers no
|
|
923
|
+
* "stop asking me this" option — a rule minted from it would never clear it). A drift between the
|
|
924
|
+
* two would let a card offer a rule the lane then refuses to honor.
|
|
925
|
+
*/
|
|
926
|
+
export declare function persistedRuleMandateOf(marks: {
|
|
927
|
+
egress?: boolean;
|
|
928
|
+
shellGated?: boolean;
|
|
929
|
+
irreversibility?: "never" | "maybe" | "always";
|
|
930
|
+
}): "operator_always" | "tool_marks" | undefined;
|
|
874
931
|
/**
|
|
875
932
|
* The design/37 **two-phase tool gate** — the single chokepoint that makes the load-bearing invariant
|
|
876
933
|
* structural ("a hook's `allow` cannot bypass the policy's `deny`/`ask`"):
|
package/dist/core/hooks.js
CHANGED
|
@@ -98,7 +98,7 @@ function preToolUseCrashReason(subject, err) {
|
|
|
98
98
|
function screenPreToolUseResult(r) {
|
|
99
99
|
if (r === undefined)
|
|
100
100
|
return undefined;
|
|
101
|
-
return refuseOutOfContractDecision(r);
|
|
101
|
+
return refuseOutOfContractDecision(r, { reasonIsNonInput: true });
|
|
102
102
|
}
|
|
103
103
|
export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
|
|
104
104
|
return brandPolicyAskClass({
|
|
@@ -125,6 +125,7 @@ export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
|
|
|
125
125
|
action: "ask",
|
|
126
126
|
message: decisionText(r) ?? `approval required for "${req.toolName}" (inherited PreToolUse hook)`,
|
|
127
127
|
decisionReason: "hook",
|
|
128
|
+
...(r.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
128
129
|
...(r.updatedInput !== undefined ? { updatedInput: r.updatedInput } : {}),
|
|
129
130
|
};
|
|
130
131
|
}
|
|
@@ -161,6 +162,17 @@ function withProbeTimeout(p, ms, signal) {
|
|
|
161
162
|
p.then((v) => done(resolve, v), (err) => done(reject, err));
|
|
162
163
|
});
|
|
163
164
|
}
|
|
165
|
+
export function persistedRuleMandateOf(marks) {
|
|
166
|
+
return marks.egress === true
|
|
167
|
+
? "tool_marks"
|
|
168
|
+
: marks.shellGated === true
|
|
169
|
+
? marks.irreversibility === "always"
|
|
170
|
+
? "operator_always"
|
|
171
|
+
: undefined
|
|
172
|
+
: marks.irreversibility === "always" || marks.irreversibility === "maybe"
|
|
173
|
+
? "tool_marks"
|
|
174
|
+
: undefined;
|
|
175
|
+
}
|
|
164
176
|
export async function runToolGate(input) {
|
|
165
177
|
const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
|
|
166
178
|
const { toolCallId, toolName } = event;
|
|
@@ -209,13 +221,31 @@ export async function runToolGate(input) {
|
|
|
209
221
|
if ((decision.action === "allow" || decision.action === "ask") && decision.updatedInput !== undefined) {
|
|
210
222
|
policyRewrite = decision.updatedInput;
|
|
211
223
|
}
|
|
212
|
-
else if (hookAsk?.
|
|
224
|
+
else if (hookAsk?.updatedInput !== undefined) {
|
|
213
225
|
policyRewrite = hookAsk.updatedInput;
|
|
214
226
|
}
|
|
215
227
|
if (decision.action === "allow" && hookAsk) {
|
|
216
|
-
decision = {
|
|
228
|
+
decision = {
|
|
229
|
+
action: "ask",
|
|
230
|
+
...(hookAsk.message !== undefined ? { message: hookAsk.message } : {}),
|
|
231
|
+
decisionReason: "hook",
|
|
232
|
+
...(hookAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
233
|
+
...(hookAsk.updatedInput !== undefined ? { updatedInput: hookAsk.updatedInput } : {}),
|
|
234
|
+
};
|
|
217
235
|
denySource = "hook";
|
|
218
236
|
}
|
|
237
|
+
else if (decision.action === "ask" && hookAsk) {
|
|
238
|
+
const hookMessage = hookAsk.message;
|
|
239
|
+
const foldedMessage = hookMessage !== undefined && hookMessage !== decision.message
|
|
240
|
+
? `${decision.message !== undefined ? `${decision.message} ` : ""}(a PreToolUse hook also asks: ${hookMessage})`
|
|
241
|
+
: decision.message;
|
|
242
|
+
decision = {
|
|
243
|
+
...decision,
|
|
244
|
+
decisionReason: "hook",
|
|
245
|
+
...(foldedMessage !== undefined ? { message: foldedMessage } : {}),
|
|
246
|
+
...(hookAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
219
249
|
if (input.egress && decision.action === "allow") {
|
|
220
250
|
decision = {
|
|
221
251
|
action: "ask",
|
|
@@ -315,15 +345,7 @@ export async function runToolGate(input) {
|
|
|
315
345
|
currentInput = policyRewrite;
|
|
316
346
|
req.args = policyRewrite;
|
|
317
347
|
}
|
|
318
|
-
const persistedRuleMandate = input
|
|
319
|
-
? "tool_marks"
|
|
320
|
-
: input.shellGated === true
|
|
321
|
-
? input.irreversibility === "always"
|
|
322
|
-
? "operator_always"
|
|
323
|
-
: undefined
|
|
324
|
-
: input.irreversibility === "always" || input.irreversibility === "maybe"
|
|
325
|
-
? "tool_marks"
|
|
326
|
-
: undefined;
|
|
348
|
+
const persistedRuleMandate = persistedRuleMandateOf(input);
|
|
327
349
|
if (input.persistedRules &&
|
|
328
350
|
!orgRealApprovalRequired &&
|
|
329
351
|
decision.action === "ask" &&
|
|
@@ -408,7 +430,7 @@ export async function runToolGate(input) {
|
|
|
408
430
|
? { origin: orgAskOrigin !== undefined ? `org_${orgAskOrigin}` : "policy" }
|
|
409
431
|
: undefined;
|
|
410
432
|
if (suspendAsk && decision.action === "ask") {
|
|
411
|
-
const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
|
|
433
|
+
const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined);
|
|
412
434
|
if (suspended) {
|
|
413
435
|
if ("parkFailed" in suspended)
|
|
414
436
|
parkFailed = suspended.parkFailed;
|
|
@@ -427,7 +449,7 @@ export async function runToolGate(input) {
|
|
|
427
449
|
req.args = outcome.presentedInput;
|
|
428
450
|
}
|
|
429
451
|
if (suspendAsk && outcome.parkDeclined && parkFailed === undefined) {
|
|
430
|
-
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
|
|
452
|
+
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined);
|
|
431
453
|
if (suspended) {
|
|
432
454
|
if ("parkFailed" in suspended)
|
|
433
455
|
parkFailed = suspended.parkFailed;
|
|
@@ -455,7 +477,7 @@ export async function runToolGate(input) {
|
|
|
455
477
|
const resolved = await resolveAsk(decision, req);
|
|
456
478
|
decision = resolved;
|
|
457
479
|
if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
|
|
458
|
-
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined);
|
|
480
|
+
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined);
|
|
459
481
|
if (suspended) {
|
|
460
482
|
if ("parkFailed" in suspended)
|
|
461
483
|
parkFailed = suspended.parkFailed;
|
|
@@ -321,7 +321,9 @@ export declare class MemoryEngine {
|
|
|
321
321
|
* eventId anchors on the pollution record (sessionId + marked-at), so however many times this
|
|
322
322
|
* settlement replays the same pollution event, the generation is allocated once. */
|
|
323
323
|
private settlePromotions;
|
|
324
|
-
materialize(scopes: readonly string[], writeScope: string | null
|
|
324
|
+
materialize(scopes: readonly string[], writeScope: string | null, opts?: {
|
|
325
|
+
adoptionRestricted?: boolean;
|
|
326
|
+
}): Promise<MemorySessionHandle>;
|
|
325
327
|
inject(handle: MemorySessionHandle, opts?: {
|
|
326
328
|
writeToolMounted?: boolean;
|
|
327
329
|
}): MemoryInjection;
|
|
@@ -365,10 +367,11 @@ export declare class MemoryEngine {
|
|
|
365
367
|
* @param keepBaseline RB-440 (2026-07-31, hardening probe P3) — relative paths whose baseline must NOT
|
|
366
368
|
* advance: the files a DEGRADED harvest deferred (`report.degraded.pending`).
|
|
367
369
|
*
|
|
368
|
-
* The deferral contract
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
370
|
+
* The deferral contract in the harvest scan (the budget/deadline arm — see `harvestDeadlineMs`)
|
|
371
|
+
* promises "the REST is deferred to the next harvest (still on disk), never dropped", but execution
|
|
372
|
+
* then continued into this scan, which re-baselined EVERY file carrying an id — the pending ones
|
|
373
|
+
* included — writing the CURRENT disk rev into `handle.baseRevs`. The next harvest hit the harvest
|
|
374
|
+
* scan's unchanged-baseline fast path and skipped the file FOREVER: the deferred edit never
|
|
372
375
|
* became a patch. Session-NEW files escaped it (no id ⇒ `continue` below), which is exactly why the
|
|
373
376
|
* existing budget test passed. Measured with a copy-out backend: harvest#1 {update:1} + pending ["b.md"],
|
|
374
377
|
* harvest#2 {update:0}, and the backend still held v1 — so the next `materialize` wrote the stale text
|
|
@@ -323,9 +323,13 @@ export class MemoryEngine {
|
|
|
323
323
|
catch {
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
|
-
async materialize(scopes, writeScope) {
|
|
326
|
+
async materialize(scopes, writeScope, opts) {
|
|
327
327
|
ensureDirExists(this.memoryDir);
|
|
328
328
|
ensureDirExists(this.controlDir);
|
|
329
|
+
const restricted = opts?.adoptionRestricted === true;
|
|
330
|
+
const readBackend = restricted
|
|
331
|
+
? (this.backend.restrictedAdoptionView?.({ audit: true, writeScope }) ?? this.backend)
|
|
332
|
+
: this.backend;
|
|
329
333
|
if (writeScope !== null)
|
|
330
334
|
claimRootScope(this.controlDir, writeScope);
|
|
331
335
|
const scopeDirs = new Map();
|
|
@@ -336,8 +340,8 @@ export class MemoryEngine {
|
|
|
336
340
|
for (const [scope, dir] of scopeDirs)
|
|
337
341
|
this.chmodScopeTree(dir, 0o755, 0o644, { excludeTopDirs: this.siblingScopeDirNames(dir, scope) });
|
|
338
342
|
const allScopes = [...scopeDirs.keys()];
|
|
339
|
-
const headers = await
|
|
340
|
-
const entries = await
|
|
343
|
+
const headers = await readBackend.listHeaders(allScopes);
|
|
344
|
+
const entries = await readBackend.getByIds(headers.map((h) => h.id));
|
|
341
345
|
const byScope = new Map();
|
|
342
346
|
for (const e of entries) {
|
|
343
347
|
const arr = byScope.get(e.scope) ?? [];
|
|
@@ -357,6 +361,7 @@ export class MemoryEngine {
|
|
|
357
361
|
repoIndexTargets: new Map(),
|
|
358
362
|
indexBaselineLines: 0,
|
|
359
363
|
indexText: "",
|
|
364
|
+
...(restricted ? { adoptionRestricted: true } : {}),
|
|
360
365
|
};
|
|
361
366
|
if (writeScope !== null)
|
|
362
367
|
ensureDirExists(handle.writableRoot);
|
|
@@ -382,7 +387,8 @@ export class MemoryEngine {
|
|
|
382
387
|
const { deleted: _stubDrop, ...stubFm } = entry.frontmatter;
|
|
383
388
|
const text = stub ? serializeEntryFile({ id: entry.id, frontmatter: stubFm, body: STUB_ARCHIVED_LINE }) : fullText;
|
|
384
389
|
const target = join(dir, `${entry.slug}.md`);
|
|
385
|
-
|
|
390
|
+
if (!(zeroCopy && restricted))
|
|
391
|
+
this.writeIfChanged(target, text);
|
|
386
392
|
const onDiskRev = stub ? revOfText(text, entry.id) : entry.rev;
|
|
387
393
|
const path = canonicalize(target);
|
|
388
394
|
handle.materialized.push({
|
|
@@ -415,7 +421,7 @@ export class MemoryEngine {
|
|
|
415
421
|
this.discloseAnnounceFailure("index-gate enqueue", err);
|
|
416
422
|
}
|
|
417
423
|
}
|
|
418
|
-
const indexText = this.rebuildIndex(handle, headers, { write: writeScope !== null, ignoreOnDisk: indexGate !== undefined });
|
|
424
|
+
const indexText = this.rebuildIndex(handle, headers, { write: writeScope !== null && !restricted, ignoreOnDisk: indexGate !== undefined || restricted });
|
|
419
425
|
handle.indexBaselineLines = countIndexLines(indexText);
|
|
420
426
|
handle.indexText = indexText;
|
|
421
427
|
for (const [scope, dir] of scopeDirs) {
|
|
@@ -433,7 +439,7 @@ export class MemoryEngine {
|
|
|
433
439
|
const instruction = writeChannel ? buildMemoryInstruction(handle.writableRoot) : "";
|
|
434
440
|
const readOnlyNotice = handle.writeScope === null ? MEMORY_READONLY_NOTICE : undefined;
|
|
435
441
|
const indexPath = join(handle.writableRoot, MEMORY_INDEX_FILENAME);
|
|
436
|
-
const onDisk = handle.indexOnDiskUntrusted === true ? undefined : readSafe(indexPath);
|
|
442
|
+
const onDisk = handle.indexOnDiskUntrusted === true || handle.adoptionRestricted === true ? undefined : readSafe(indexPath);
|
|
437
443
|
const indexText = onDisk !== undefined && onDisk.trim() !== "" ? onDisk : handle.indexText;
|
|
438
444
|
const truncated = truncateIndex(indexText);
|
|
439
445
|
const index = composeMemoryBlock(truncated, handle.writeScope ?? handle.scopes[0] ?? "memory");
|
|
@@ -529,6 +535,12 @@ export class MemoryEngine {
|
|
|
529
535
|
};
|
|
530
536
|
const writeScope = handle.writeScope;
|
|
531
537
|
if (writeScope === null || opts?.admitNothing !== undefined) {
|
|
538
|
+
try {
|
|
539
|
+
this.backend.auditRestrictedDivergence?.([...new Set([...handle.scopes, ...(writeScope !== null ? [writeScope] : [])])], handle.adoptionRestricted === true && writeScope !== null ? { idlessWriteScope: writeScope } : undefined);
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
report.warnings.push(`restricted-session divergence audit failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
543
|
+
}
|
|
532
544
|
const roFindings = this.backend.drainInboundFindings?.();
|
|
533
545
|
if (roFindings !== undefined && roFindings.length > 0)
|
|
534
546
|
report.inboundFindings = roFindings;
|