@remembrance-ai/opencode-plugin 0.1.77 → 0.2.1
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/README.md +172 -32
- package/opencode.json +2 -2
- package/package.json +1 -1
- package/scripts/feedback-disposition.mjs +376 -0
- package/scripts/hook-core.mjs +6145 -685
- package/scripts/pending-action-payload.mjs +1044 -0
- package/scripts/private-learning-hook-policy.mjs +2515 -0
- package/servers/remembrance-mcp.mjs +62249 -6956
- package/skills/remembrancer/SKILL.md +219 -105
- package/skills/remembrancer/references/identity-preferences-topology.md +41 -0
- package/skills/remembrancer/references/remembrance-payloads.md +94 -17
- package/skills/remembrancer/references/remembrance-setup.md +186 -74
- package/src/index.mjs +131 -40
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Connects Remembrance to [opencode](https://opencode.ai): the MCP server that
|
|
4
4
|
exposes the Remembrance tools, plus a plugin that records activation health,
|
|
5
|
-
observes Remembrance tool use, and
|
|
6
|
-
idle.
|
|
5
|
+
observes Remembrance tool use, and records unfinished feedback/capture work
|
|
6
|
+
silently when a session goes idle so the next prompt can recover it.
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
@@ -18,8 +18,18 @@ server to `~/.config/opencode/opencode.json`. It preserves comments and unrelate
|
|
|
18
18
|
settings and creates a new config with mode `0600` when none exists.
|
|
19
19
|
|
|
20
20
|
Restart opencode. Remembrance then appears in its tools list, adds bounded
|
|
21
|
-
matching guidance to eligible model turns, observes completed use, and
|
|
22
|
-
for
|
|
21
|
+
matching guidance to eligible model turns, observes completed use, and silently
|
|
22
|
+
queues any missing feedback or capture action for the next real prompt.
|
|
23
|
+
|
|
24
|
+
**Node.js runtime.** The MCP entry that setup writes runs `npx`, so Node.js
|
|
25
|
+
18.17.0 or newer and npm must be on the PATH opencode starts with. A host
|
|
26
|
+
started from the Dock, Finder or an IDE may not have your shell's PATH, so a
|
|
27
|
+
Node.js installed by nvm, fnm, volta, asdf, nodenv or mise can be missing from
|
|
28
|
+
it; the symptom is `spawn node ENOENT` or "No such file or directory". `npx`
|
|
29
|
+
starts `node` through PATH, so an absolute `npx` alone is not enough: if the
|
|
30
|
+
host cannot find them, set `command` to the absolute `npx` (`command -v npx` in
|
|
31
|
+
a terminal) and put that directory first in the entry's environment `PATH`, for
|
|
32
|
+
example `/Users/you/.nvm/versions/node/v22.12.0/bin:/usr/bin:/bin`.
|
|
23
33
|
|
|
24
34
|
At session creation, the plugin performs a bounded, credential-free release
|
|
25
35
|
check. If a newer verified version exists, opencode shows a notice and gives the
|
|
@@ -44,8 +54,8 @@ The stable plugin API exposes `chat.message` and
|
|
|
44
54
|
`experimental.chat.system.transform`. Remembrance uses them together to run the
|
|
45
55
|
fail-open query helper and inject bounded guidance before an eligible model
|
|
46
56
|
turn. Empty or unavailable queries never block the task. The plugin also
|
|
47
|
-
observes successful tool calls and
|
|
48
|
-
use.
|
|
57
|
+
observes successful tool calls and records pending feedback after meaningful
|
|
58
|
+
use for bounded recovery on the next prompt.
|
|
49
59
|
|
|
50
60
|
## Installation identity and preferences
|
|
51
61
|
|
|
@@ -64,10 +74,14 @@ working preferences follow each engineer across approved agents and steer
|
|
|
64
74
|
relevant public and team skills without changing shared instructions or
|
|
65
75
|
weakening organization policy. Profiles, observations, compatibility records,
|
|
66
76
|
and feedback remain private to the organization. An explicit instruction
|
|
67
|
-
governs the current task immediately. Known built-ins activate durably at once
|
|
77
|
+
governs the current task immediately. Known built-ins activate durably at once for a
|
|
78
|
+
managed-encryption organization.
|
|
68
79
|
Custom preferences remain pending until automatic normalization and validation
|
|
69
80
|
approves them; unsafe, malformed, or uncertain custom behavior stays inactive
|
|
70
|
-
and is never replayed to an agent.
|
|
81
|
+
and is never replayed to an agent.
|
|
82
|
+
An organization using customer-held encryption is currently deferred: every substantive preference write and every compatibility-feedback call is refused before persistence with HTTP 422 and code `customer_held_preference_processing_deferred`. Existing records are unchanged, reads and the content-free withdrawal lane keep working, and responses withhold the compatibility-feedback offer there. Read `activated` and
|
|
83
|
+
`normalized_activation_state` in the response instead of assuming a setting is
|
|
84
|
+
live. Classification runs
|
|
71
85
|
asynchronously against exact skill versions; query and invocation add no
|
|
72
86
|
generative preference call or second embedding request. Material compatibility
|
|
73
87
|
may reorder only already-relevant skills inside one match tier or apply a
|
|
@@ -132,12 +146,48 @@ link-local self-host also requires `REMEMBRANCE_ALLOW_PRIVATE_REGISTRY=true`.
|
|
|
132
146
|
| `chat.message` | Runs the fail-open query helper for the current user turn. |
|
|
133
147
|
| `experimental.chat.system.transform` | Adds bounded Remembrance guidance before model dispatch. |
|
|
134
148
|
| `tool.execute.after` | Correlates query, invocation, detail, and contribution calls. |
|
|
135
|
-
| `session.idle` |
|
|
136
|
-
|
|
149
|
+
| `session.idle` | Records bounded pending contribution work for the next prompt. |
|
|
150
|
+
|
|
151
|
+
Host-generated text a trigger word matches (a Codex commit or pull-request
|
|
152
|
+
generation prompt, a subagent hand-back, pasted `git status` output, an explicit
|
|
153
|
+
local skill invocation) skips only the skill search: the organization policy,
|
|
154
|
+
required settings and preferences a search would carry still arrive; set
|
|
155
|
+
`REMEMBRANCE_AUTO_QUERY_HYGIENE=0` to search it anyway.
|
|
156
|
+
Besides prompts a keyword trigger matches, the hook searches any other
|
|
157
|
+
task-shaped prompt of at least 40 characters (trigger reason `task_shape`).
|
|
158
|
+
A task-shape search that finds nothing adds nothing of its own: no no-match
|
|
159
|
+
block, no completion recovery and no query-feedback request. Its organization
|
|
160
|
+
policy, required settings, preferences and capture lines still arrive, and the
|
|
161
|
+
server still records the missing-skill demand.
|
|
162
|
+
Set `REMEMBRANCE_AUTO_QUERY_TASK_SHAPE=0` to search keyword-triggered prompts
|
|
163
|
+
only.
|
|
164
|
+
Without an organization key, the server's relevance judge chooses what a search
|
|
165
|
+
is shown from the top 20 public candidates: Jev (TypeSafe, through Vercel AI
|
|
166
|
+
Gateway) rates each candidate first, and a language model decides the ones Jev
|
|
167
|
+
leaves undecided while there is time. It receives the search's task text and
|
|
168
|
+
public skill cards only. Every request asks for zero retention and no prompt
|
|
169
|
+
training; for Jev, zero retention rests on Remembrance's account-specific
|
|
170
|
+
agreement with TypeSafe through Vercel, which the Gateway does not confirm.
|
|
171
|
+
With an organization key, a search is judged only under the organization's
|
|
172
|
+
managed processing grant for semantic search, and Jev rates it first only when
|
|
173
|
+
that grant also approves TypeSafe, under the owner-attested zero-retention
|
|
174
|
+
agreement with TypeSafe (owner decisions 36 and 64), which covers this judge
|
|
175
|
+
stage and no other purpose: its task text and skill cards, the organization's
|
|
176
|
+
own included, go only to the hosts that grant approves, each asked for zero
|
|
177
|
+
retention and no prompt training. Without that grant nothing is sent and the
|
|
178
|
+
search keeps the rule's list.
|
|
179
|
+
A card shows a skill's use and exclusion conditions only where the
|
|
180
|
+
platform generated them or a person reviewed them. A use condition only
|
|
181
|
+
promotes a result, and only an exclusion its author stated or a person
|
|
182
|
+
reviewed makes it `unlikely`; `applicability.provenance` says who stated
|
|
183
|
+
them.
|
|
184
|
+
Set `REMEMBRANCE_AUTO_QUERY_STRIP_INJECTED_CONTEXT=1` to search only the text
|
|
185
|
+
after a leading `<compartment_examples_from_other_projects>` block (off by
|
|
186
|
+
default).
|
|
137
187
|
Set `REMEMBRANCE_AUTO_QUERY=0` to disable the query, or
|
|
138
|
-
`REMEMBRANCE_AUTO_CONTRIBUTE=0` to disable
|
|
139
|
-
|
|
140
|
-
|
|
188
|
+
`REMEMBRANCE_AUTO_CONTRIBUTE=0` to disable silent completion recovery. The
|
|
189
|
+
idle event does not emit routine contribution text as a toast or structured log;
|
|
190
|
+
the next real user prompt receives only the exact bounded recovery actions.
|
|
141
191
|
|
|
142
192
|
## Host policy
|
|
143
193
|
|
|
@@ -145,18 +195,29 @@ A tenant or privacy-policy denial in the host happens before Remembrance is
|
|
|
145
195
|
called, so it is not a Remembrance misconfiguration — the same boundary as a
|
|
146
196
|
Codex tenant/privacy-policy denial, which occurs before Remembrance is reached
|
|
147
197
|
and must not be reported as a Remembrance setup failure. The plugin reports one
|
|
148
|
-
local, content-free alert: **
|
|
149
|
-
|
|
198
|
+
local, content-free alert: **The host blocked this Remembrance action. Do not
|
|
199
|
+
treat it as submitted. Earlier successful actions are unaffected; querying remains available.** It does
|
|
150
200
|
not retry or automatically create a handoff.
|
|
151
201
|
|
|
152
|
-
### Private
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
routine
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
202
|
+
### Private learning autopilot
|
|
203
|
+
|
|
204
|
+
Current organization-capable local clients expose `capture_learning` as the
|
|
205
|
+
only routine reusable-learning entry point. Supply exactly one compact
|
|
206
|
+
`lesson`, complete `private_skill`, or structured durable `preference`. It
|
|
207
|
+
accepts no organization, destination, visibility, credential, or public
|
|
208
|
+
fallback. Lessons are canonicalized and redacted in memory; complete skills
|
|
209
|
+
retain bounded markdown, public citations, snippets, metadata, and actionable
|
|
210
|
+
instructions. Lesson and skill drafts are encrypted in separate outboxes; a
|
|
211
|
+
preference returns the exact existing `record_preference` action.
|
|
212
|
+
`capture_learning` is local and makes no network request.
|
|
213
|
+
|
|
214
|
+
Invoke only the returned `submit_private_lesson_candidate`,
|
|
215
|
+
`submit_private_skill_candidate`, or `record_preference` action.
|
|
216
|
+
These actions always submit privately. Lesson and skill actions carry only a local draft ID
|
|
217
|
+
through OpenCode to the authenticated organization's pipeline and never falls
|
|
218
|
+
back to anonymous or public submission. Accepted content reuses the existing verifier,
|
|
219
|
+
topology, review, versioning, audit, and admin-controlled propagation pipeline;
|
|
220
|
+
the submitting agent never chooses visibility. After the private outcome is accepted,
|
|
160
221
|
a separate server-owned process may create a freshly redacted public candidate
|
|
161
222
|
only when the organization admin enabled **Contribution propagation**; the
|
|
162
223
|
submitting agent neither chooses nor sees that process, and the derivative must
|
|
@@ -165,20 +226,99 @@ or auto-delete. Verified submissions discard encrypted lesson content
|
|
|
165
226
|
immediately; their content-free completion markers are automatically deleted
|
|
166
227
|
after 14 days.
|
|
167
228
|
|
|
168
|
-
The
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
229
|
+
The lesson client offers a bounded `private-lesson-redaction-v2` supported
|
|
230
|
+
redactor-profile set and the
|
|
231
|
+
server signs policy for the newest mutual profile. Retained drafts require
|
|
232
|
+
their exact bound profile. Temporary incompatibility remains encrypted as
|
|
233
|
+
`awaiting_compatible_profile` with precise update, restart, or server-rollout
|
|
234
|
+
guidance; it is not mislabeled as safety or host denial. A profile becomes
|
|
235
|
+
terminal only when the installed client no longer supports it; that state is
|
|
236
|
+
`superseded_redactor`. Terminal drafts stay encrypted and are never retried, re-redacted, or automatically deleted.
|
|
237
|
+
When health reporting is enabled, a held draft may send
|
|
172
238
|
only content-free category/version counts and signed protocol digests through
|
|
173
239
|
the same approved action; that telemetry cannot enter verification, review,
|
|
174
240
|
topology, or skill materialization. Disable it with
|
|
175
241
|
`REMEMBRANCE_HEALTH_REPORTING=0`.
|
|
176
242
|
|
|
177
|
-
OpenCode permission keys match MCP tool names.
|
|
178
|
-
`
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
243
|
+
OpenCode permission keys match MCP tool names. Allow the submit and retry tools
|
|
244
|
+
for the `private_lesson` and `private_skill` lanes; broader Remembrance write
|
|
245
|
+
tools can remain `ask` or `deny`. Remembrance maps an existing submit approval
|
|
246
|
+
to its lane. Local capture, inspection, and confirmed deletion remain
|
|
247
|
+
zero-network; a fresh-draft retry stops before network access. Broad `submit_remembrance` and
|
|
248
|
+
`propose_private_skill` are legacy/hosted/raw compatibility routes and are
|
|
249
|
+
unavailable by default locally. `submit_query_feedback` remains independently
|
|
250
|
+
available for one complete good/partial/poor query-fit verdict set;
|
|
251
|
+
`submit_feedback` is categorical post-use feedback. A host denial is held and
|
|
252
|
+
is never retried through another transport.
|
|
253
|
+
|
|
254
|
+
### Routing metadata on complete skill proposals
|
|
255
|
+
|
|
256
|
+
In 0.2.0 a complete skill sent with `propose_skill_idea` or
|
|
257
|
+
`propose_private_skill` (or their REST routes) carries routing metadata
|
|
258
|
+
(contract v1, published at `/schemas/skill-routing-metadata.v1.json`): when
|
|
259
|
+
an agent should pick the skill (`applicability`) and what it does and needs
|
|
260
|
+
(`routing`). Ask for a platform draft first, show it to the author, and send
|
|
261
|
+
the confirmed or edited fields back with the draft's token. The draft call
|
|
262
|
+
stores nothing:
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
{
|
|
266
|
+
"title": "Deploy a prepared app to Azure",
|
|
267
|
+
"description": "Deploy an already-prepared app to Azure with azd.",
|
|
268
|
+
"proposed_skill_md": "# Deploy a prepared app to Azure\n\nRun azd up ...",
|
|
269
|
+
"prefill_routing_metadata": true
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Its answer holds `example.proposed_metadata` and `routing_draft.token`. The
|
|
274
|
+
submission then sends the confirmed fields:
|
|
275
|
+
|
|
276
|
+
```json
|
|
277
|
+
{
|
|
278
|
+
"title": "Deploy a prepared app to Azure",
|
|
279
|
+
"description": "Deploy an already-prepared app to Azure with azd.",
|
|
280
|
+
"proposed_skill_md": "# Deploy a prepared app to Azure\n\nRun azd up ...",
|
|
281
|
+
"proposed_metadata": {
|
|
282
|
+
"applicability": {
|
|
283
|
+
"scope": "specialized",
|
|
284
|
+
"use_when": [
|
|
285
|
+
"The user wants to deploy an already-prepared app to Azure",
|
|
286
|
+
"A deployment plan and infrastructure files already exist",
|
|
287
|
+
"The user asks to run azd up or azd deploy"
|
|
288
|
+
],
|
|
289
|
+
"avoid_when": [
|
|
290
|
+
"The app has no Azure deployment files yet (prepare it first)",
|
|
291
|
+
"The user only wants a readiness check without deploying"
|
|
292
|
+
],
|
|
293
|
+
"task_examples": [
|
|
294
|
+
"Deploy this prepared app to Azure Container Apps",
|
|
295
|
+
"Run azd up for my project",
|
|
296
|
+
"Push the validated Bicep deployment to production"
|
|
297
|
+
],
|
|
298
|
+
"not_for": []
|
|
299
|
+
},
|
|
300
|
+
"routing": {
|
|
301
|
+
"capabilities": ["deploy-to-cloud"],
|
|
302
|
+
"domains": ["deployments-cicd", "cloud-platforms"],
|
|
303
|
+
"runtime_requirements": ["repo-checkout", "shell", "cloud-account", "provider-azure"],
|
|
304
|
+
"risk": "cloud-changes"
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
"routing_draft_token": "rd1.<token from the draft>"
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
A complete skill without these fields is refused with 422
|
|
312
|
+
`skill_routing_metadata_incomplete` (`delivery: not_delivered`; nothing is
|
|
313
|
+
stored). The refusal lists every issue and carries the same draft, so fix
|
|
314
|
+
the listed fields and send the request again. Fields taken unchanged are
|
|
315
|
+
recorded as author-confirmed draft values; edits that widen the draft (a
|
|
316
|
+
broader scope, added `use_when` items or capabilities, a lower risk) are
|
|
317
|
+
flagged for review. An organization's text is drafted only through its
|
|
318
|
+
approved semantic-enrichment processing grant; without one the draft is held
|
|
319
|
+
and nothing is sent. A private capture (`capture_learning` with
|
|
320
|
+
`private_skill`) may ask for automatic completion: the skill becomes
|
|
321
|
+
available after acceptance once its routing metadata is complete.
|
|
182
322
|
|
|
183
323
|
## Generated files
|
|
184
324
|
|
package/opencode.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"command": [
|
|
7
7
|
"npx",
|
|
8
8
|
"-y",
|
|
9
|
-
"@remembrance-ai/mcp-server@0.1
|
|
9
|
+
"@remembrance-ai/mcp-server@0.2.1"
|
|
10
10
|
],
|
|
11
11
|
"enabled": true,
|
|
12
12
|
"environment": {
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"plugin": [
|
|
19
|
-
"@remembrance-ai/opencode-plugin@0.1
|
|
19
|
+
"@remembrance-ai/opencode-plugin@0.2.1"
|
|
20
20
|
]
|
|
21
21
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
constants,
|
|
6
|
+
fstatSync,
|
|
7
|
+
fsyncSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
openSync,
|
|
11
|
+
opendirSync,
|
|
12
|
+
readSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
|
|
20
|
+
const FEEDBACK_DENIAL_VERSION = "feedback-denial-acknowledgement-v1";
|
|
21
|
+
const FEEDBACK_DENIAL_SUBDIRECTORY = "feedback-denials";
|
|
22
|
+
const FEEDBACK_DENIAL_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
23
|
+
const MAX_FEEDBACK_IDENTIFIER_LENGTH = 512;
|
|
24
|
+
const MAX_FEEDBACK_DENIAL_MARKER_BYTES = 1024;
|
|
25
|
+
const MAX_FEEDBACK_DENIAL_MARKERS = 512;
|
|
26
|
+
const MARKER_NAME_PATTERN = /^[a-f0-9]{64}\.json$/u;
|
|
27
|
+
|
|
28
|
+
class FeedbackDenialStorageError extends Error {
|
|
29
|
+
constructor() {
|
|
30
|
+
super("The host-policy denial acknowledgement could not be persisted.");
|
|
31
|
+
this.name = "FeedbackDenialStorageError";
|
|
32
|
+
this.code = "feedback_denial_acknowledgement_failed";
|
|
33
|
+
this.status = "acknowledgement_failed";
|
|
34
|
+
this.acknowledged = false;
|
|
35
|
+
this.submitted = false;
|
|
36
|
+
this.local_only = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeInput(input) {
|
|
41
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
|
|
42
|
+
const keys = Object.keys(input).sort();
|
|
43
|
+
const expectedKeys =
|
|
44
|
+
input.tool === "submit_feedback"
|
|
45
|
+
? ["query_id", "reason", "result_id", "tool"]
|
|
46
|
+
: ["query_id", "reason", "tool"];
|
|
47
|
+
if (
|
|
48
|
+
keys.length !== expectedKeys.length ||
|
|
49
|
+
keys.some((key, index) => key !== expectedKeys[index]) ||
|
|
50
|
+
!["submit_query_feedback", "submit_feedback"].includes(input.tool) ||
|
|
51
|
+
input.reason !== "host_policy_denied" ||
|
|
52
|
+
!validIdentifier(input.query_id)
|
|
53
|
+
) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (input.tool === "submit_feedback") {
|
|
57
|
+
if (!validIdentifier(input.result_id)) return null;
|
|
58
|
+
return {
|
|
59
|
+
tool: input.tool,
|
|
60
|
+
query_id: input.query_id,
|
|
61
|
+
result_id: input.result_id,
|
|
62
|
+
reason: input.reason,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if ("result_id" in input) return null;
|
|
66
|
+
return {
|
|
67
|
+
tool: input.tool,
|
|
68
|
+
query_id: input.query_id,
|
|
69
|
+
reason: input.reason,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validIdentifier(value) {
|
|
74
|
+
return (
|
|
75
|
+
typeof value === "string" &&
|
|
76
|
+
value.length > 0 &&
|
|
77
|
+
value.length <= MAX_FEEDBACK_IDENTIFIER_LENGTH
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function exactEventDigest(input) {
|
|
82
|
+
const fields = [input.tool, input.query_id, input.result_id ?? ""];
|
|
83
|
+
const hash = createHash("sha256");
|
|
84
|
+
for (const field of fields) {
|
|
85
|
+
const bytes = Buffer.from(field, "utf8");
|
|
86
|
+
hash.update(String(bytes.length), "utf8");
|
|
87
|
+
hash.update(":", "utf8");
|
|
88
|
+
hash.update(bytes);
|
|
89
|
+
hash.update(";", "utf8");
|
|
90
|
+
}
|
|
91
|
+
return hash.digest("hex");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function localUsageOwnerId() {
|
|
95
|
+
const uid = currentUserId();
|
|
96
|
+
if (uid !== null) return `uid-${uid}`;
|
|
97
|
+
return `home-${createHash("sha256")
|
|
98
|
+
.update(homedir())
|
|
99
|
+
.digest("hex")
|
|
100
|
+
.slice(0, 16)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function currentUserId() {
|
|
104
|
+
try {
|
|
105
|
+
const uid = userInfo().uid;
|
|
106
|
+
if (Number.isSafeInteger(uid) && uid >= 0) return uid;
|
|
107
|
+
} catch {
|
|
108
|
+
// Non-POSIX runtimes fall back to the same home hash as hook-core.
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function usageDirectory(env) {
|
|
114
|
+
return env?.REMEMBRANCE_USAGE_DIR
|
|
115
|
+
? String(env.REMEMBRANCE_USAGE_DIR)
|
|
116
|
+
: join(tmpdir(), `remembrance-usage-${localUsageOwnerId()}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function markerDirectory(env) {
|
|
120
|
+
return join(usageDirectory(env), FEEDBACK_DENIAL_SUBDIRECTORY);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function markerPath(input, env) {
|
|
124
|
+
return join(markerDirectory(env), `${exactEventDigest(input)}.json`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isPosix() {
|
|
128
|
+
return currentUserId() !== null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function assertCurrentOwner(uid) {
|
|
132
|
+
const currentUid = currentUserId();
|
|
133
|
+
if (currentUid !== null && uid !== currentUid) {
|
|
134
|
+
throw new Error("Local state is not owned by the current user.");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function ensurePrivateDirectory(path) {
|
|
139
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
140
|
+
const metadata = lstatSync(path);
|
|
141
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
142
|
+
throw new Error("Local state is not a regular directory.");
|
|
143
|
+
}
|
|
144
|
+
assertCurrentOwner(metadata.uid);
|
|
145
|
+
if (isPosix() && (metadata.mode & 0o077) !== 0) {
|
|
146
|
+
chmodSync(path, 0o700);
|
|
147
|
+
const repaired = lstatSync(path);
|
|
148
|
+
assertCurrentOwner(repaired.uid);
|
|
149
|
+
if ((repaired.mode & 0o077) !== 0) {
|
|
150
|
+
throw new Error("Local state directory permissions are not private.");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function assertPrivateDirectory(path) {
|
|
156
|
+
const metadata = lstatSync(path);
|
|
157
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
158
|
+
throw new Error("Local state is not a regular directory.");
|
|
159
|
+
}
|
|
160
|
+
assertCurrentOwner(metadata.uid);
|
|
161
|
+
if (isPosix() && (metadata.mode & 0o022) !== 0) {
|
|
162
|
+
throw new Error("Local state directory is writable by another user.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function assertPrivateMarker(metadata) {
|
|
167
|
+
if (
|
|
168
|
+
metadata.isSymbolicLink() ||
|
|
169
|
+
!metadata.isFile() ||
|
|
170
|
+
metadata.size > MAX_FEEDBACK_DENIAL_MARKER_BYTES
|
|
171
|
+
) {
|
|
172
|
+
throw new Error("Local state marker is unsafe.");
|
|
173
|
+
}
|
|
174
|
+
assertCurrentOwner(metadata.uid);
|
|
175
|
+
if (isPosix() && (metadata.mode & 0o077) !== 0) {
|
|
176
|
+
throw new Error("Local state marker permissions are not private.");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readBoundedDescriptor(descriptor) {
|
|
181
|
+
const chunks = [];
|
|
182
|
+
let total = 0;
|
|
183
|
+
while (total <= MAX_FEEDBACK_DENIAL_MARKER_BYTES) {
|
|
184
|
+
const chunk = Buffer.allocUnsafe(
|
|
185
|
+
Math.min(1024, MAX_FEEDBACK_DENIAL_MARKER_BYTES + 1 - total),
|
|
186
|
+
);
|
|
187
|
+
const bytesRead = readSync(descriptor, chunk, 0, chunk.length, null);
|
|
188
|
+
if (bytesRead === 0) break;
|
|
189
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
190
|
+
total += bytesRead;
|
|
191
|
+
}
|
|
192
|
+
if (total > MAX_FEEDBACK_DENIAL_MARKER_BYTES) {
|
|
193
|
+
throw new Error("Local state marker exceeds its size limit.");
|
|
194
|
+
}
|
|
195
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function readMarker(path, expectedDigest) {
|
|
199
|
+
assertPrivateDirectory(dirname(dirname(path)));
|
|
200
|
+
assertPrivateDirectory(dirname(path));
|
|
201
|
+
const before = lstatSync(path);
|
|
202
|
+
assertPrivateMarker(before);
|
|
203
|
+
const descriptor = openSync(
|
|
204
|
+
path,
|
|
205
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
206
|
+
);
|
|
207
|
+
try {
|
|
208
|
+
const opened = fstatSync(descriptor);
|
|
209
|
+
assertPrivateMarker(opened);
|
|
210
|
+
if (before.dev !== opened.dev || before.ino !== opened.ino) {
|
|
211
|
+
throw new Error("Local state marker changed while opening.");
|
|
212
|
+
}
|
|
213
|
+
const parsed = JSON.parse(readBoundedDescriptor(descriptor));
|
|
214
|
+
if (
|
|
215
|
+
!parsed ||
|
|
216
|
+
typeof parsed !== "object" ||
|
|
217
|
+
Array.isArray(parsed) ||
|
|
218
|
+
parsed.version !== FEEDBACK_DENIAL_VERSION ||
|
|
219
|
+
parsed.event_digest !== `sha256:${expectedDigest}` ||
|
|
220
|
+
typeof parsed.acknowledged_at !== "string" ||
|
|
221
|
+
typeof parsed.expires_at !== "string" ||
|
|
222
|
+
!Number.isFinite(Date.parse(parsed.acknowledged_at)) ||
|
|
223
|
+
!Number.isFinite(Date.parse(parsed.expires_at))
|
|
224
|
+
) {
|
|
225
|
+
throw new Error("Local state marker is malformed.");
|
|
226
|
+
}
|
|
227
|
+
return parsed;
|
|
228
|
+
} finally {
|
|
229
|
+
closeSync(descriptor);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function removeExpiredMarker(path, marker, now) {
|
|
234
|
+
if (Date.parse(marker.expires_at) > now) return false;
|
|
235
|
+
rmSync(path, { force: true });
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function cleanupExpiredMarkers(directory, now) {
|
|
240
|
+
const handle = opendirSync(directory);
|
|
241
|
+
let markerCount = 0;
|
|
242
|
+
let visited = 0;
|
|
243
|
+
try {
|
|
244
|
+
while (visited <= MAX_FEEDBACK_DENIAL_MARKERS) {
|
|
245
|
+
const entry = handle.readSync();
|
|
246
|
+
if (!entry) break;
|
|
247
|
+
visited += 1;
|
|
248
|
+
if (!entry.isFile() || !MARKER_NAME_PATTERN.test(entry.name)) continue;
|
|
249
|
+
markerCount += 1;
|
|
250
|
+
const digest = entry.name.slice(0, -".json".length);
|
|
251
|
+
const path = join(directory, entry.name);
|
|
252
|
+
try {
|
|
253
|
+
if (removeExpiredMarker(path, readMarker(path, digest), now)) {
|
|
254
|
+
markerCount -= 1;
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// One corrupt marker cannot acknowledge or remove another event.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (visited > MAX_FEEDBACK_DENIAL_MARKERS) {
|
|
261
|
+
throw new Error("Local acknowledgement storage exceeds its entry limit.");
|
|
262
|
+
}
|
|
263
|
+
} finally {
|
|
264
|
+
handle.closeSync();
|
|
265
|
+
}
|
|
266
|
+
if (markerCount >= MAX_FEEDBACK_DENIAL_MARKERS) {
|
|
267
|
+
throw new Error("Local acknowledgement storage is full.");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function writeMarkerAtomically(path, marker) {
|
|
272
|
+
const temporaryPath = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
273
|
+
let descriptor;
|
|
274
|
+
try {
|
|
275
|
+
descriptor = openSync(
|
|
276
|
+
temporaryPath,
|
|
277
|
+
constants.O_WRONLY |
|
|
278
|
+
constants.O_CREAT |
|
|
279
|
+
constants.O_EXCL |
|
|
280
|
+
(constants.O_NOFOLLOW ?? 0),
|
|
281
|
+
0o600,
|
|
282
|
+
);
|
|
283
|
+
writeFileSync(descriptor, `${JSON.stringify(marker)}\n`, {
|
|
284
|
+
encoding: "utf8",
|
|
285
|
+
});
|
|
286
|
+
fsyncSync(descriptor);
|
|
287
|
+
closeSync(descriptor);
|
|
288
|
+
descriptor = undefined;
|
|
289
|
+
renameSync(temporaryPath, path);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (descriptor !== undefined) {
|
|
292
|
+
try {
|
|
293
|
+
closeSync(descriptor);
|
|
294
|
+
} catch {
|
|
295
|
+
// Preserve the original persistence failure.
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
rmSync(temporaryPath, { force: true });
|
|
300
|
+
} catch {
|
|
301
|
+
// Preserve the original persistence failure.
|
|
302
|
+
}
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function acknowledgeFeedbackDenial(input, env = process.env) {
|
|
308
|
+
const normalized = normalizeInput(input);
|
|
309
|
+
if (!normalized) {
|
|
310
|
+
throw new TypeError("Invalid feedback denial acknowledgement input.");
|
|
311
|
+
}
|
|
312
|
+
const digest = exactEventDigest(normalized);
|
|
313
|
+
const directory = markerDirectory(env);
|
|
314
|
+
const path = markerPath(normalized, env);
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
try {
|
|
317
|
+
ensurePrivateDirectory(usageDirectory(env));
|
|
318
|
+
ensurePrivateDirectory(directory);
|
|
319
|
+
cleanupExpiredMarkers(directory, now);
|
|
320
|
+
try {
|
|
321
|
+
const existing = readMarker(path, digest);
|
|
322
|
+
if (Date.parse(existing.expires_at) > now) {
|
|
323
|
+
return {
|
|
324
|
+
status: "acknowledged_host_denial",
|
|
325
|
+
acknowledged: true,
|
|
326
|
+
submitted: false,
|
|
327
|
+
local_only: true,
|
|
328
|
+
already_acknowledged: true,
|
|
329
|
+
expires_at: existing.expires_at,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
rmSync(path, { force: true });
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (error?.code !== "ENOENT") throw error;
|
|
335
|
+
}
|
|
336
|
+
const acknowledgedAt = new Date(now).toISOString();
|
|
337
|
+
const expiresAt = new Date(
|
|
338
|
+
now + FEEDBACK_DENIAL_RETENTION_MS,
|
|
339
|
+
).toISOString();
|
|
340
|
+
writeMarkerAtomically(path, {
|
|
341
|
+
version: FEEDBACK_DENIAL_VERSION,
|
|
342
|
+
event_digest: `sha256:${digest}`,
|
|
343
|
+
acknowledged_at: acknowledgedAt,
|
|
344
|
+
expires_at: expiresAt,
|
|
345
|
+
});
|
|
346
|
+
const persisted = readMarker(path, digest);
|
|
347
|
+
if (Date.parse(persisted.expires_at) <= now) {
|
|
348
|
+
throw new Error("Local acknowledgement expired before persistence.");
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
status: "acknowledged_host_denial",
|
|
352
|
+
acknowledged: true,
|
|
353
|
+
submitted: false,
|
|
354
|
+
local_only: true,
|
|
355
|
+
already_acknowledged: false,
|
|
356
|
+
expires_at: persisted.expires_at,
|
|
357
|
+
};
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw new FeedbackDenialStorageError();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function feedbackDenialAcknowledged(input, env = process.env) {
|
|
364
|
+
const normalized = normalizeInput(input);
|
|
365
|
+
if (!normalized) return false;
|
|
366
|
+
const digest = exactEventDigest(normalized);
|
|
367
|
+
const path = markerPath(normalized, env);
|
|
368
|
+
try {
|
|
369
|
+
const marker = readMarker(path, digest);
|
|
370
|
+
const now = Date.now();
|
|
371
|
+
if (removeExpiredMarker(path, marker, now)) return false;
|
|
372
|
+
return true;
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|