@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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { closeSync, constants as fsConstants, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
2
|
-
const { O_WRONLY, O_CREAT, O_TRUNC, O_NOFOLLOW } = fsConstants;
|
|
1
|
+
import { closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
2
|
+
const { O_WRONLY, O_CREAT, O_TRUNC, O_NOFOLLOW, O_EXCL } = fsConstants;
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
@@ -184,6 +184,119 @@ export function scopeDirName(scope) {
|
|
|
184
184
|
const hash = createHash("sha256").update(scope, "utf8").digest("hex").slice(0, 6);
|
|
185
185
|
return `${cleaned}-${hash}`;
|
|
186
186
|
}
|
|
187
|
+
const caseFoldByDirIdentity = new Map();
|
|
188
|
+
let caseFoldProbeSeq = 0;
|
|
189
|
+
function lstatOrAbsent(p) {
|
|
190
|
+
try {
|
|
191
|
+
return lstatSync(p);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
if (err.code === "ENOENT")
|
|
195
|
+
return undefined;
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function probeDirCaseFolds(dir) {
|
|
200
|
+
const name = `.casefold-probe-${process.pid}-${caseFoldProbeSeq++}`;
|
|
201
|
+
const writeLeg = (() => {
|
|
202
|
+
let fd;
|
|
203
|
+
try {
|
|
204
|
+
fd = openSync(join(dir, name), O_WRONLY | O_CREAT | O_EXCL, 0o600);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
let identity;
|
|
210
|
+
try {
|
|
211
|
+
const st = fstatSync(fd);
|
|
212
|
+
identity = { dev: st.dev, ino: st.ino };
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
closeSync(fd);
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
let swapped;
|
|
221
|
+
let orig;
|
|
222
|
+
try {
|
|
223
|
+
swapped = lstatOrAbsent(join(dir, name.toUpperCase()));
|
|
224
|
+
orig = swapped === undefined ? lstatOrAbsent(join(dir, name)) : undefined;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
if (swapped !== undefined) {
|
|
230
|
+
return identity === undefined || (swapped.dev === identity.dev && swapped.ino === identity.ino) ? true : undefined;
|
|
231
|
+
}
|
|
232
|
+
if (orig === undefined || identity === undefined)
|
|
233
|
+
return undefined;
|
|
234
|
+
return orig.dev === identity.dev && orig.ino === identity.ino ? false : undefined;
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
try {
|
|
238
|
+
rmSync(join(dir, name), { force: true });
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
})();
|
|
244
|
+
if (writeLeg !== undefined)
|
|
245
|
+
return writeLeg;
|
|
246
|
+
try {
|
|
247
|
+
for (const entry of readdirSync(dir)) {
|
|
248
|
+
if (!/[A-Za-z]/.test(entry))
|
|
249
|
+
continue;
|
|
250
|
+
const swapped = entry.toLowerCase() !== entry ? entry.toLowerCase() : entry.toUpperCase();
|
|
251
|
+
try {
|
|
252
|
+
const orig = lstatOrAbsent(join(dir, entry));
|
|
253
|
+
if (orig === undefined)
|
|
254
|
+
continue;
|
|
255
|
+
const other = lstatOrAbsent(join(dir, swapped));
|
|
256
|
+
const again = lstatOrAbsent(join(dir, entry));
|
|
257
|
+
if (again === undefined || again.dev !== orig.dev || again.ino !== orig.ino)
|
|
258
|
+
continue;
|
|
259
|
+
if (other === undefined)
|
|
260
|
+
return false;
|
|
261
|
+
return other.dev === orig.dev && other.ino === orig.ino;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
export function dirCaseFolds(dir) {
|
|
273
|
+
let key;
|
|
274
|
+
try {
|
|
275
|
+
ensureDirExists(dir);
|
|
276
|
+
const s = statSync(dir);
|
|
277
|
+
key = `${s.dev}:${s.ino}`;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
}
|
|
281
|
+
if (key !== undefined) {
|
|
282
|
+
const cached = caseFoldByDirIdentity.get(key);
|
|
283
|
+
if (cached !== undefined)
|
|
284
|
+
return cached;
|
|
285
|
+
}
|
|
286
|
+
const folds = probeDirCaseFolds(dir);
|
|
287
|
+
if (folds === undefined)
|
|
288
|
+
return undefined;
|
|
289
|
+
try {
|
|
290
|
+
const after = statSync(dir);
|
|
291
|
+
if (key === undefined || key !== `${after.dev}:${after.ino}`)
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
caseFoldByDirIdentity.set(key, folds);
|
|
298
|
+
return folds;
|
|
299
|
+
}
|
|
187
300
|
function readScopesRecord(controlDir) {
|
|
188
301
|
const path = join(controlDir, SCOPES_FILE);
|
|
189
302
|
let raw;
|
|
@@ -217,6 +330,11 @@ function readScopesRecord(controlDir) {
|
|
|
217
330
|
for (const [k, v] of Object.entries(rec.scopes)) {
|
|
218
331
|
if (typeof v !== "string")
|
|
219
332
|
throw new ControlPlaneCorruptError(`scope registry entry ${JSON.stringify(k)} is not a string: ${path}`);
|
|
333
|
+
if ((v === "") !== (k === rec.rootScope)) {
|
|
334
|
+
throw new ControlPlaneCorruptError(v === ""
|
|
335
|
+
? `scope registry maps ${JSON.stringify(k)} to the root home but rootScope is ${rec.rootScope === undefined ? "unclaimed" : JSON.stringify(rec.rootScope)}: ${path}`
|
|
336
|
+
: `scope registry maps the root scope ${JSON.stringify(k)} to subdir ${JSON.stringify(v)} instead of the root home: ${path}`);
|
|
337
|
+
}
|
|
220
338
|
}
|
|
221
339
|
scopes = rec.scopes;
|
|
222
340
|
}
|
|
@@ -255,20 +373,26 @@ export function claimRootScope(controlDir, scope) {
|
|
|
255
373
|
return { next: { rootScope: scope, scopes: { ...rec.scopes, [scope]: "" } }, result: scope };
|
|
256
374
|
});
|
|
257
375
|
}
|
|
258
|
-
export function registerScope(memoryDir, controlDir, scope) {
|
|
376
|
+
export function registerScope(memoryDir, controlDir, scope, opts) {
|
|
259
377
|
return lockedScopesUpdate(controlDir, (rec) => {
|
|
260
|
-
const dirName = rec.rootScope === scope ? "" : scopeDirName(scope);
|
|
378
|
+
const dirName = rec.rootScope === scope ? "" : (rec.scopes?.[scope] ?? scopeDirName(scope));
|
|
261
379
|
for (const [other, otherDir] of Object.entries(rec.scopes ?? {})) {
|
|
262
|
-
if (other
|
|
380
|
+
if (other === scope || otherDir === "")
|
|
381
|
+
continue;
|
|
382
|
+
if (otherDir === dirName) {
|
|
263
383
|
throw new ControlPlaneCorruptError(`scope directory collision: ${JSON.stringify(scope)} and ${JSON.stringify(other)} both map to ${JSON.stringify(dirName)} — refusing (fail-closed)`);
|
|
264
384
|
}
|
|
385
|
+
if (dirName !== "" && otherDir.toLowerCase() === dirName.toLowerCase()) {
|
|
386
|
+
const folds = opts?.caseFoldingFs ?? dirCaseFolds(memoryDir);
|
|
387
|
+
if (folds !== false) {
|
|
388
|
+
throw new ControlPlaneCorruptError(`scope directory collision on a case-folding filesystem: ${JSON.stringify(scope)} → ${JSON.stringify(dirName)} and ${JSON.stringify(other)} → ${JSON.stringify(otherDir)} are one physical directory under ${memoryDir}` +
|
|
389
|
+
`${folds === undefined ? " (volume case semantics could not be probed — refusing the fold-equal pair rather than risking a silent cross-scope merge)" : ""} — refusing (fail-closed)`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
265
392
|
}
|
|
266
393
|
if (rec.scopes?.[scope] === undefined) {
|
|
267
394
|
return { next: { ...rec, scopes: { ...rec.scopes, [scope]: dirName } }, result: dirName === "" ? memoryDir : join(memoryDir, dirName) };
|
|
268
395
|
}
|
|
269
|
-
if (rec.scopes[scope] !== dirName) {
|
|
270
|
-
return { result: rec.scopes[scope] === "" ? memoryDir : join(memoryDir, rec.scopes[scope]) };
|
|
271
|
-
}
|
|
272
396
|
return { result: dirName === "" ? memoryDir : join(memoryDir, dirName) };
|
|
273
397
|
});
|
|
274
398
|
}
|
|
@@ -223,9 +223,17 @@ export interface MemorySessionHandle {
|
|
|
223
223
|
* the gate's whole purpose is keeping those bytes out of the prompt, so a refused clear must not
|
|
224
224
|
* leave the injection path reading them anyway. Absent ⇒ the normal "live file wins" behavior. */
|
|
225
225
|
indexOnDiskUntrusted?: boolean;
|
|
226
|
+
/** True ⇔ this session materialized through the ADOPTION-RESTRICTED (committed-view) read face:
|
|
227
|
+
* the caller declared the session unable to persist (an explicit session-level verdict — never
|
|
228
|
+
* inferred down here, and never derived from the plane's shape: a read-only layering
|
|
229
|
+
* (`writeScope === null`) keeps its ordinary adopt-on-read semantics and does NOT set this).
|
|
230
|
+
* Restricted sessions read committed state only (ledger + control-plane shadow); disk divergence
|
|
231
|
+
* with no transaction backing is neither adopted into the committed account nor served, and
|
|
232
|
+
* `inject` reads the materialize-time index text instead of the live on-disk file. */
|
|
233
|
+
adoptionRestricted?: boolean;
|
|
226
234
|
}
|
|
227
235
|
/** Stable rejection codes a harvest gate can produce (model-visible gate events — 镜头 I). */
|
|
228
|
-
export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "polluted" | "invalid";
|
|
236
|
+
export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "polluted" | "restricted_divergence" | "invalid";
|
|
229
237
|
/** One rejected file: path (relative to the memory dir), stable code, and a model-readable reason. */
|
|
230
238
|
export interface HarvestRejection {
|
|
231
239
|
path: string;
|
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
* MySQL is NOT "can't support vectors" — it is `portable`-capable (JSON column + in-process cosine), just not
|
|
14
14
|
* `native`. A deployment injects an embedder (config-driven); the store then reports the achieved rung.
|
|
15
15
|
*/
|
|
16
|
-
/** Lower-cased
|
|
16
|
+
/** Lower-cased lexical term set: alphanumeric runs PLUS CJK character bigrams (a single-character
|
|
17
|
+
* run contributes its unigram). CJK scripts have no `a-z0-9` runs at all, so an alphanumeric-only
|
|
18
|
+
* tokenizer made every pure-CJK entry an EMPTY set — never a lexical-rung candidate for any query
|
|
19
|
+
* — and a pure-CJK query returned nothing; character bigrams are the standard analyzer unit there
|
|
20
|
+
* (word boundaries are not written). Bigrams stay within one run: adjacency across an intervening
|
|
21
|
+
* non-CJK character is not real adjacency. */
|
|
17
22
|
export declare function termSet(s: string): Set<string>;
|
|
18
23
|
/** Lexical stand-in distance: `1 - Jaccard(terms)` ∈ [0,1] ⊂ [0,2]; `null` = no overlap (not a candidate). */
|
|
19
24
|
export declare function jaccardDistance(query: Set<string>, text: string): number | null;
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
const CJK_RUN = /[\u3040-\u30FF\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7A3\uF900-\uFAFF]+/g;
|
|
1
2
|
export function termSet(s) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
const lower = s.toLowerCase();
|
|
4
|
+
const out = new Set();
|
|
5
|
+
for (const run of lower.split(/[^a-z0-9]+/))
|
|
6
|
+
if (run)
|
|
7
|
+
out.add(run);
|
|
8
|
+
for (const m of lower.matchAll(CJK_RUN)) {
|
|
9
|
+
const chars = [...m[0]];
|
|
10
|
+
for (const c of chars)
|
|
11
|
+
out.add(c);
|
|
12
|
+
for (let i = 0; i + 1 < chars.length; i++)
|
|
13
|
+
out.add(chars[i] + chars[i + 1]);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
6
16
|
}
|
|
7
17
|
export function jaccardDistance(query, text) {
|
|
8
18
|
const t = termSet(text);
|
package/dist/core/memory.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { uuidv7 } from "../internal/harness.js";
|
|
2
2
|
import { parseScopeKey } from "./memory-engine/scope-contract.js";
|
|
3
|
+
import { termSet } from "./memory-vector.js";
|
|
3
4
|
import { sanitizeUntrustedText } from "./untrusted-text.js";
|
|
4
5
|
export function supportsConsolidation(store) {
|
|
5
6
|
return (typeof store.searchScored === "function" &&
|
|
@@ -148,12 +149,6 @@ export function guardedMemoryStore(inner, utilityGate) {
|
|
|
148
149
|
function renderBullet(e) {
|
|
149
150
|
return `- (${e.ts} UTC) ${e.text}`;
|
|
150
151
|
}
|
|
151
|
-
function termSet(s) {
|
|
152
|
-
return new Set(s
|
|
153
|
-
.toLowerCase()
|
|
154
|
-
.split(/[^a-z0-9]+/)
|
|
155
|
-
.filter(Boolean));
|
|
156
|
-
}
|
|
157
152
|
export class InMemoryMemoryStore {
|
|
158
153
|
byScope = new Map();
|
|
159
154
|
cursorByScope = new Map();
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* this by editing a file backend's file. That is the settings-file trust model, stated rather than
|
|
28
28
|
* defended against: for a file backend, host = user, no more and no less.
|
|
29
29
|
*/
|
|
30
|
-
import { type RuleScope, type RuleDot } from "./permission-rule-model.js";
|
|
30
|
+
import { type RuleRejectCode, type RuleScope, type RuleDot } from "./permission-rule-model.js";
|
|
31
31
|
import type { PermissionRuleStoreProvider, RuleOwner } from "./permission-rule-store.js";
|
|
32
32
|
/** One candidate rule inside an approval record: the exact text and where it would apply. */
|
|
33
33
|
export interface RuleCandidate {
|
|
@@ -52,9 +52,19 @@ export interface RuleApprovalRecord {
|
|
|
52
52
|
state: "pending" | "approved" | "redeemed";
|
|
53
53
|
candidates: RuleCandidate[];
|
|
54
54
|
createdAt: string;
|
|
55
|
-
/** The ask this record was drawn from, for reconciliation. Advisory metadata; never adjudication input.
|
|
55
|
+
/** The ask this record was drawn from, for reconciliation. Advisory metadata; never adjudication input.
|
|
56
|
+
* `boundInputHash` is ALSO the card-edit binding anchor: an edited-candidate confirmation must echo
|
|
57
|
+
* it back, so a record minted without one refuses edits (there is nothing to bind the edit to). */
|
|
56
58
|
toolCallId?: string;
|
|
57
59
|
boundInputHash?: string;
|
|
60
|
+
/**
|
|
61
|
+
* The adjudicated command a CARD record was drawn for — the same post-rewrite bytes the ask carried
|
|
62
|
+
* (the form `boundInputHash` digests). Additive: absent on batch records and on every card a prior
|
|
63
|
+
* version minted. It is the edit gate's coverage input ("the edited rule must still admit THIS
|
|
64
|
+
* command"), so a record without one refuses edited candidates rather than guessing; the engine-
|
|
65
|
+
* candidate paths never read it.
|
|
66
|
+
*/
|
|
67
|
+
command?: string;
|
|
58
68
|
/** Monotonic revision of THIS record, bumped by every accepted transition. The compare-and-set key:
|
|
59
69
|
* comparing state alone cannot separate two different writes that both leave the state unchanged. */
|
|
60
70
|
rev: number;
|
|
@@ -68,6 +78,20 @@ export interface RuleApprovalRecord {
|
|
|
68
78
|
* previewed list by construction.
|
|
69
79
|
*/
|
|
70
80
|
selectedCandidate?: number;
|
|
81
|
+
/**
|
|
82
|
+
* The person-EDITED candidate this record carries, if any — full provenance for the one candidate
|
|
83
|
+
* whose text was authored at the card rather than derived by the engine. `index` names the appended
|
|
84
|
+
* row in `candidates` (whose `rule` holds the CANONICAL spelling); `text` keeps the raw input bytes
|
|
85
|
+
* exactly as submitted (the idempotency primary key — a client retrying a lost response resends the
|
|
86
|
+
* same bytes); `at` is when the edit landed. Present ⇒ `selectedCandidate === index` (the edit and
|
|
87
|
+
* the choice are one CAS write). Absent on every record a prior version minted and on every card
|
|
88
|
+
* settled through an engine candidate.
|
|
89
|
+
*/
|
|
90
|
+
edited?: {
|
|
91
|
+
index: number;
|
|
92
|
+
text: string;
|
|
93
|
+
at: string;
|
|
94
|
+
};
|
|
71
95
|
/** Dots already minted for this record, keyed by candidate index — the replay anchor. */
|
|
72
96
|
redeemedDots?: Record<number, RuleDot>;
|
|
73
97
|
}
|
|
@@ -95,6 +119,16 @@ export interface RuleConsentDeps {
|
|
|
95
119
|
/** Injectable clock/id for deterministic tests; defaults are the real ones. */
|
|
96
120
|
now?: () => Date;
|
|
97
121
|
newId?: () => string;
|
|
122
|
+
/**
|
|
123
|
+
* Deployment lever for the card-edit face: whether `confirmRuleApproval` accepts a FRESH
|
|
124
|
+
* `editedCandidate`. Absent or `false` = OFF (the default — the edit face widens what a fabricated
|
|
125
|
+
* confirmation could mint, from "one of the engine's bounded candidates" to "any same-head rule
|
|
126
|
+
* passing the coverage gate", so it is opt-in). Any other non-boolean value is a configuration
|
|
127
|
+
* mistake and refuses LOUDLY at the read — never silently mapped to a default. Replaying an edit a
|
|
128
|
+
* record already settled is a pure record read and does not consult this switch: the minting already
|
|
129
|
+
* happened, and withholding the receipt helps no one.
|
|
130
|
+
*/
|
|
131
|
+
cardEdits?: boolean;
|
|
98
132
|
}
|
|
99
133
|
/** In-memory approval records — the test backend and the reference CAS semantics. */
|
|
100
134
|
export declare class InMemoryRuleApprovalRecordStore implements RuleApprovalRecordStore {
|
|
@@ -157,21 +191,61 @@ export declare function confirmRuleApproval(opts: {
|
|
|
157
191
|
/** design/182 §4.5 (additive): the structural owner — only the local-owner path needs it. */
|
|
158
192
|
owner?: RuleOwner;
|
|
159
193
|
/**
|
|
160
|
-
* REQUIRED for a card record: the index of the option the person
|
|
161
|
-
* different breadth, so "they said yes" is not an answer on
|
|
162
|
-
* Rejected on a batch record, whose confirmation covers
|
|
194
|
+
* REQUIRED for a card record settled through an ENGINE candidate: the index of the option the person
|
|
195
|
+
* chose. A card presents alternatives of different breadth, so "they said yes" is not an answer on
|
|
196
|
+
* its own — "they said yes to THIS one" is. Rejected on a batch record, whose confirmation covers
|
|
197
|
+
* the previewed list by construction. Mutually exclusive with `editedCandidate`.
|
|
163
198
|
*/
|
|
164
199
|
selectedCandidate?: number;
|
|
200
|
+
/**
|
|
201
|
+
* The person-EDITED rule text for this card, travelling on the SAME authenticated confirmation
|
|
202
|
+
* channel as a choice among the engine's candidates (never the un-authenticated prepare entry, which
|
|
203
|
+
* keeps refusing caller candidates). `text` is the rule as authored; `boundInputHash` must echo the
|
|
204
|
+
* record's own bound-input digest — the submitter's proof of "I am editing the card that showed THIS
|
|
205
|
+
* command", an in-process mis-binding fence (a caller holding only a leaked approvalId cannot spell
|
|
206
|
+
* it), not a cryptographic one. The engine validates the text through the one shared validator,
|
|
207
|
+
* requires it to still ADMIT the adjudicated command, appends it as a new candidate and binds the
|
|
208
|
+
* selection to it, returning the minted ticket. Mutually exclusive with `selectedCandidate`.
|
|
209
|
+
*/
|
|
210
|
+
editedCandidate?: {
|
|
211
|
+
text: string;
|
|
212
|
+
boundInputHash: string;
|
|
213
|
+
};
|
|
165
214
|
deps: RuleConsentDeps;
|
|
166
215
|
}): Promise<ConfirmResult>;
|
|
167
|
-
/** Why a confirmation did not land. A closed set so a host can branch (re-present, re-fetch, give up).
|
|
168
|
-
|
|
169
|
-
|
|
216
|
+
/** Why a confirmation did not land. A closed set so a host can branch (re-present, re-fetch, give up).
|
|
217
|
+
* The three `edit_*` members are the card-edit face's own refusals:
|
|
218
|
+
* - `"edit_disabled"` — the deployment has not opted into card edits (`RuleConsentDeps.cardEdits`);
|
|
219
|
+
* - `"edit_binding_mismatch"` — the confirmation does not echo the record's bound-input digest
|
|
220
|
+
* (missing echo, a record minted without one, or a different card). Refused BEFORE anything else,
|
|
221
|
+
* settled replays included, and never returns a minted ticket;
|
|
222
|
+
* - `"edit_rejected"` — the edited text failed a gate (validator refusal, coverage, record shape);
|
|
223
|
+
* `detail` carries the specifics. */
|
|
224
|
+
export type ConfirmRefusalReason = "record_not_found" | "selection_missing" | "selection_invalid" | "selection_mismatch" | "batch_takes_no_selection" | "not_pending" | "conflict" | "edit_disabled" | "edit_binding_mismatch" | "edit_rejected";
|
|
225
|
+
/** The confirmation outcome: landed, or refused with a named reason.
|
|
226
|
+
*
|
|
227
|
+
* `mintedCandidate` (additive) is present exactly when an EDITED candidate settled this confirmation —
|
|
228
|
+
* fresh mint and idempotent replay alike (a client retrying a lost response gets the same index, the
|
|
229
|
+
* same canonical rule text and the same deterministically re-minted ticket, never a push toward a
|
|
230
|
+
* second card). `rule` is the CANONICAL spelling, which may differ from the submitted bytes (spelling
|
|
231
|
+
* normalization); a surface echoes it back so the person sees the form that will actually persist.
|
|
232
|
+
*
|
|
233
|
+
* `detail` (additive, refusal arm) rides `edit_rejected`: `code` is the shared validator's refusal
|
|
234
|
+
* code when the validator is what refused, absent when another gate did; `message` always says why. */
|
|
170
235
|
export type ConfirmResult = {
|
|
171
236
|
ok: true;
|
|
237
|
+
mintedCandidate?: {
|
|
238
|
+
index: number;
|
|
239
|
+
rule: string;
|
|
240
|
+
ticket: RuleTicket;
|
|
241
|
+
};
|
|
172
242
|
} | {
|
|
173
243
|
ok: false;
|
|
174
244
|
reason: ConfirmRefusalReason;
|
|
245
|
+
detail?: {
|
|
246
|
+
code?: RuleRejectCode;
|
|
247
|
+
message: string;
|
|
248
|
+
};
|
|
175
249
|
};
|
|
176
250
|
/** What a redemption produced. `alreadyRedeemed` marks the replay path — the same dot, no second rule. */
|
|
177
251
|
export type RedeemResult = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { parseAllowRuleText, suggestRulesForCommand } from "./permission-rule-model.js";
|
|
2
|
+
import { parseAllowRuleText, ruleAdmitsCommand, suggestRulesForCommand } from "./permission-rule-model.js";
|
|
3
3
|
import { errText, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
|
|
4
4
|
export class InMemoryRuleApprovalRecordStore {
|
|
5
5
|
rows = new Map();
|
|
@@ -109,6 +109,7 @@ export async function prepareCardApproval(opts) {
|
|
|
109
109
|
state: "pending",
|
|
110
110
|
rev: 0,
|
|
111
111
|
candidates,
|
|
112
|
+
command: opts.command,
|
|
112
113
|
createdAt: nowIso(opts.deps),
|
|
113
114
|
...(opts.toolCallId !== undefined ? { toolCallId: opts.toolCallId } : {}),
|
|
114
115
|
...(opts.boundInputHash !== undefined ? { boundInputHash: opts.boundInputHash } : {}),
|
|
@@ -120,10 +121,17 @@ const CARD_RULE_TOOL = "Bash";
|
|
|
120
121
|
export async function confirmRuleApproval(opts) {
|
|
121
122
|
const caller = resolveCallerOwner(opts.principal, opts.owner, "confirmRuleApproval");
|
|
122
123
|
const no = (reason) => ({ ok: false, reason });
|
|
124
|
+
if (opts.editedCandidate !== undefined && opts.selectedCandidate !== undefined) {
|
|
125
|
+
const e = new Error("confirmRuleApproval takes selectedCandidate OR editedCandidate, never both — one confirmation carries one choice");
|
|
126
|
+
e.code = "config.invalid_argument";
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
123
129
|
const rec = await opts.deps.approvals.get(opts.approvalId);
|
|
124
130
|
const recOwner = rec === undefined ? undefined : ownerOfRecord(rec);
|
|
125
131
|
if (rec === undefined || recOwner === undefined || !sameRuleOwner(recOwner, caller))
|
|
126
132
|
return no("record_not_found");
|
|
133
|
+
if (opts.editedCandidate !== undefined)
|
|
134
|
+
return await confirmEditedCandidate(rec, opts.editedCandidate, opts.deps);
|
|
127
135
|
if (rec.kind === "card") {
|
|
128
136
|
const chosen = opts.selectedCandidate;
|
|
129
137
|
if (chosen === undefined)
|
|
@@ -145,6 +153,89 @@ export async function confirmRuleApproval(opts) {
|
|
|
145
153
|
const won = await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved" });
|
|
146
154
|
return won ? { ok: true } : no("conflict");
|
|
147
155
|
}
|
|
156
|
+
function normalizeEditedSpelling(text) {
|
|
157
|
+
const m = /^([A-Za-z][A-Za-z0-9_]*)\((.+) \*\)$/.exec(text);
|
|
158
|
+
if (m === null)
|
|
159
|
+
return text;
|
|
160
|
+
const head = m[1];
|
|
161
|
+
const body = m[2];
|
|
162
|
+
if (head === undefined || body === undefined || body.includes("*"))
|
|
163
|
+
return text;
|
|
164
|
+
return `${head}(${body}:*)`;
|
|
165
|
+
}
|
|
166
|
+
async function confirmEditedCandidate(rec, edit, deps) {
|
|
167
|
+
const no = (reason, detail) => ({
|
|
168
|
+
ok: false,
|
|
169
|
+
reason,
|
|
170
|
+
...(detail !== undefined ? { detail } : {}),
|
|
171
|
+
});
|
|
172
|
+
if (rec.kind !== "card") {
|
|
173
|
+
return no("edit_rejected", { message: "a batch record takes no edited candidate — its confirmation covers the previewed list whole" });
|
|
174
|
+
}
|
|
175
|
+
if (typeof edit.boundInputHash !== "string" || edit.boundInputHash === "")
|
|
176
|
+
return no("edit_binding_mismatch");
|
|
177
|
+
if (rec.boundInputHash === undefined) {
|
|
178
|
+
return no("edit_binding_mismatch");
|
|
179
|
+
}
|
|
180
|
+
if (edit.boundInputHash !== rec.boundInputHash)
|
|
181
|
+
return no("edit_binding_mismatch");
|
|
182
|
+
if (rec.state === "approved" || rec.state === "redeemed") {
|
|
183
|
+
if (rec.edited === undefined)
|
|
184
|
+
return no("selection_mismatch");
|
|
185
|
+
const canonical = rec.candidates[rec.edited.index]?.rule;
|
|
186
|
+
if (canonical === undefined)
|
|
187
|
+
return no("selection_mismatch");
|
|
188
|
+
let hit = edit.text === rec.edited.text;
|
|
189
|
+
if (!hit) {
|
|
190
|
+
const reparsed = parseAllowRuleText(normalizeEditedSpelling(edit.text));
|
|
191
|
+
hit = "rule" in reparsed && reparsed.rule.rule === canonical;
|
|
192
|
+
}
|
|
193
|
+
if (!hit)
|
|
194
|
+
return no("selection_mismatch");
|
|
195
|
+
return { ok: true, mintedCandidate: { index: rec.edited.index, rule: canonical, ticket: mintRuleTicket(rec.id, rec.edited.index) } };
|
|
196
|
+
}
|
|
197
|
+
if (rec.state !== "pending")
|
|
198
|
+
return no("not_pending");
|
|
199
|
+
if (deps.cardEdits !== undefined && typeof deps.cardEdits !== "boolean") {
|
|
200
|
+
throw new Error(`RuleConsentDeps.cardEdits must be a boolean when present (got ${typeof deps.cardEdits}) — refusing to guess whether the card-edit face is enabled`);
|
|
201
|
+
}
|
|
202
|
+
if (deps.cardEdits !== true)
|
|
203
|
+
return no("edit_disabled");
|
|
204
|
+
const scope = rec.candidates[0]?.scope;
|
|
205
|
+
if (scope === undefined || !rec.candidates.every((c) => sameScope(c.scope, scope))) {
|
|
206
|
+
return no("edit_rejected", { message: "the record's candidates carry no single common scope — an edited candidate inherits the card's scope, and a record without one is malformed" });
|
|
207
|
+
}
|
|
208
|
+
if (rec.command === undefined) {
|
|
209
|
+
return no("edit_rejected", { message: "the record does not carry the adjudicated command (minted before card edits existed) — coverage cannot be verified, so the edit is refused" });
|
|
210
|
+
}
|
|
211
|
+
const parsed = parseAllowRuleText(normalizeEditedSpelling(edit.text));
|
|
212
|
+
if ("reject" in parsed)
|
|
213
|
+
return no("edit_rejected", { code: parsed.reject.code, message: parsed.reject.message });
|
|
214
|
+
if (!ruleAdmitsCommand(parsed.rule, rec.command)) {
|
|
215
|
+
return no("edit_rejected", {
|
|
216
|
+
message: `the edited rule "${parsed.rule.rule}" does not admit the command that was decided ("${rec.command}") — a card's edit may widen how much the rule covers, never move it to a different grant`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const index = rec.candidates.length;
|
|
220
|
+
const next = {
|
|
221
|
+
...rec,
|
|
222
|
+
rev: rec.rev + 1,
|
|
223
|
+
state: "approved",
|
|
224
|
+
candidates: [...rec.candidates, { rule: parsed.rule.rule, scope }],
|
|
225
|
+
selectedCandidate: index,
|
|
226
|
+
edited: { index, text: edit.text, at: nowIso(deps) },
|
|
227
|
+
};
|
|
228
|
+
const won = await deps.approvals.cas(rec.id, rec.rev, next);
|
|
229
|
+
if (!won) {
|
|
230
|
+
const again = await deps.approvals.get(rec.id);
|
|
231
|
+
if (again === undefined)
|
|
232
|
+
return no("record_not_found");
|
|
233
|
+
if (again.state === "pending")
|
|
234
|
+
return no("conflict");
|
|
235
|
+
return await confirmEditedCandidate(again, edit, deps);
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, mintedCandidate: { index, rule: parsed.rule.rule, ticket: mintRuleTicket(rec.id, index) } };
|
|
238
|
+
}
|
|
148
239
|
export async function redeemRuleTicket(opts) {
|
|
149
240
|
const caller = resolveCallerOwner(opts.principal, opts.owner, "redeemRuleTicket");
|
|
150
241
|
const parsed = parseRuleTicket(opts.ticket);
|
|
@@ -94,7 +94,7 @@ export interface RuleTombstone {
|
|
|
94
94
|
deletedBy: RuleDot;
|
|
95
95
|
}
|
|
96
96
|
/** Why a rule text was refused. Codes are stable so an import report can group by them. */
|
|
97
|
-
export type RuleRejectCode = "invalid.grammar" | "invalid.empty_command" | "invalid.not_simple_command" | "invalid.bare_interpreter_prefix" | "invalid.unbalanced_quotes" | "invalid.too_long" | "unsupported.tool" | "unsupported.wildcard";
|
|
97
|
+
export type RuleRejectCode = "invalid.grammar" | "invalid.empty_command" | "invalid.not_simple_command" | "invalid.bare_interpreter_prefix" | "invalid.unbalanced_quotes" | "invalid.control_chars" | "invalid.too_long" | "unsupported.tool" | "unsupported.wildcard";
|
|
98
98
|
export interface RuleReject {
|
|
99
99
|
code: RuleRejectCode;
|
|
100
100
|
message: string;
|
|
@@ -120,6 +120,69 @@ export declare const MAX_RULE_TEXT_CHARS = 512;
|
|
|
120
120
|
* an EXACT rule naming a whole interpreter command line stays legal, since it authorizes one command.
|
|
121
121
|
*/
|
|
122
122
|
export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
|
|
123
|
+
/**
|
|
124
|
+
* design/185 §1 — the reviewed command/subcommand grammar the PREFIX suggestion is generated from
|
|
125
|
+
* (exactly the "reviewed command/subcommand grammar" the generator's history note names as the one
|
|
126
|
+
* thing that would let it produce a prefix).
|
|
127
|
+
*
|
|
128
|
+
* A flat set of BODIES — word sequences, each at least two words. A prefix candidate exists for a
|
|
129
|
+
* command iff some body here is a word-boundary prefix of its folded form, and the LONGEST hit wins:
|
|
130
|
+
* the deeper body is the narrower rule, so listing (or not listing) a deeper body is how this table
|
|
131
|
+
* sets suggestion granularity per branch. No groups, no denylist, and no fallback arm: a head outside
|
|
132
|
+
* the table, an unreviewed subcommand, a runtime-defined name (a git alias, a `git-<x>`/`cargo-<x>`
|
|
133
|
+
* external subcommand, a gh extension, a kubectl plugin), a flag or operand in a body position and a
|
|
134
|
+
* quoted token all fail the same way — by not being listed. Closure comes from positive enumeration
|
|
135
|
+
* itself, never from an exclusion list racing names that only exist at runtime.
|
|
136
|
+
*
|
|
137
|
+
* Review criteria — every row must pass BOTH axes (the same principle as the interpreter refusal
|
|
138
|
+
* above: the rule text must not read narrower than what it grants):
|
|
139
|
+
* · "runs what it is told to": a body whose use is fetching or naming a program to execute
|
|
140
|
+
* (`npm exec`, `docker run`, `kubectl exec`, `gh extension`, `git submodule foreach`, the install
|
|
141
|
+
* family) is refused — one click cannot be read as having granted arbitrary execution. Running the
|
|
142
|
+
* WORKSPACE'S OWN pinned content (`npm run`, `npm ci`, `cargo run`, `cargo test`) is inside the
|
|
143
|
+
* boundary: the scripts and lockfiles those execute are checked into the repository being worked on.
|
|
144
|
+
* · "rewrites what others execute": a body whose main use is writing configuration that changes what
|
|
145
|
+
* OTHER commands later run (`git config` — hooksPath/pager/alias; `kubectl config` —
|
|
146
|
+
* exec-credential; `npm config`/`npm set` — script-shell; `go env` — persisted GOFLAGS/GOBIN) is
|
|
147
|
+
* refused — its readable width and its real width differ by a whole composition surface.
|
|
148
|
+
* Past both axes there is deliberately NO "dangerousness" axis: `git push:*` and `git rebase:*` are
|
|
149
|
+
* wide but readable, and a person nodding at that text is granting exactly that.
|
|
150
|
+
*
|
|
151
|
+
* Residual width, stated rather than hidden (a reviewed trade, not an oversight): a prefix rule
|
|
152
|
+
* admits ANY arguments after its body, and some listed bodies carry flags that name a program to
|
|
153
|
+
* execute (`go build`/`go test`/`go vet -toolexec`, `git fetch --upload-pack`, `git rebase -x`,
|
|
154
|
+
* `git grep -O`, `git push --receive-pack`). The axes judge a body's MAIN use, not every flag —
|
|
155
|
+
* per-flag grammar is the road this module's history rejected twice, and applied consistently it
|
|
156
|
+
* would empty the table. Three standing fences hold that residue: the org deny/ask layer runs ahead
|
|
157
|
+
* of the rule lane and cannot be silenced by it; a mandated ask (egress/irreversibility marks,
|
|
158
|
+
* shellGate:"always") is not rule-clearable either; and the narrower exact candidate — plus minting
|
|
159
|
+
* no rule at all — is always on the same card.
|
|
160
|
+
*
|
|
161
|
+
* Maintenance: adding a row is a reviewed change — keep the per-group reasoning beside it current.
|
|
162
|
+
* The integrity pins (every body ≥ 2 words, lowercase word shape, no interpreter heads, no
|
|
163
|
+
* duplicates) are enforced by this module's test suite. This table and the read-only classifier's
|
|
164
|
+
* allowlists are DIFFERENT instruments and must never be merged or cross-referenced: that one is a
|
|
165
|
+
* machine auto-allow face whose criterion is "provably read-only"; this one is a human suggestion
|
|
166
|
+
* face whose criterion is "width a person can read off the rule text". One guards against a machine
|
|
167
|
+
* loosening; the other against a person being misled.
|
|
168
|
+
*/
|
|
169
|
+
export declare const SUGGESTION_LEXICON: readonly string[];
|
|
170
|
+
/**
|
|
171
|
+
* Render a peer- or file-controlled value for a DISCLOSURE line (a warning, a refusal message, an
|
|
172
|
+
* operator log). Takes `unknown` on purpose: most of these values are typed but arrive off a wire or a
|
|
173
|
+
* file, so the runtime value can be anything, and a signature that demanded a string would push a bare
|
|
174
|
+
* `String(x)` to every call site — the exact step that gets forgotten. Rule texts are the motivating case; the same treatment is owed to every untrusted
|
|
175
|
+
* field a line interpolates (a wire `reason`, an actor id), since the hazard is the character class,
|
|
176
|
+
* not which field carries it.
|
|
177
|
+
*
|
|
178
|
+
* Quoting a refused text verbatim would carry the exact sequence the refusal exists to keep off a
|
|
179
|
+
* display surface, and would print the two texts a reader has to tell apart — `Bash(echo hi)` and
|
|
180
|
+
* `Bash(echo<ZWSP>hi)` — identically, so the report could not name WHICH rule it means. Every character
|
|
181
|
+
* {@link CONTROL_CHARS_RE} covers is therefore printed as its `\uXXXX` escape (`\u{XXXXX}` above the
|
|
182
|
+
* BMP — the TAG block U+E0020–U+E007F is a `\p{Cf}` family that lives there); everything else passes
|
|
183
|
+
* through, so an ordinary rule text reads normally. The result is length-bounded.
|
|
184
|
+
*/
|
|
185
|
+
export declare function escapeForDisclosure(value: unknown): string;
|
|
123
186
|
/**
|
|
124
187
|
* Parse one rule text into its canonical shape, or refuse it with a reason.
|
|
125
188
|
*
|
|
@@ -184,11 +247,29 @@ export interface RuleSuggestion {
|
|
|
184
247
|
/**
|
|
185
248
|
* The 1-2 candidates offered on an approval card for `command`.
|
|
186
249
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
250
|
+
* **Array order is a documented CONTRACT, not an implementation accident**: display order = array
|
|
251
|
+
* order = narrowest first. The EXACT form (this whole command line) is always index 0 whenever
|
|
252
|
+
* anything is offered at all; a broader reviewed PREFIX form — at most one — follows at index 1.
|
|
253
|
+
* Selection indices and redemption tickets are index-keyed against this order (a card's
|
|
254
|
+
* `selectedCandidate` and its `rt.<index>.` tickets), so consumers may rely on it.
|
|
255
|
+
*
|
|
256
|
+
* The prefix candidate comes ONLY from {@link SUGGESTION_LEXICON} — the longest reviewed body that is
|
|
257
|
+
* a word-boundary prefix of the folded command. History, and why there is no heuristic arm: two
|
|
258
|
+
* rounds of guessing produced two different wrong answers — a bare program name (`rm -f x` →
|
|
259
|
+
* `Bash(rm:*)`), then an operand mistaken for a subcommand (`rm harmless.txt` →
|
|
260
|
+
* `Bash(rm harmless.txt:*)`, which admits a second, unnamed target) — and both failed the same way:
|
|
261
|
+
* nothing in the command TEXT distinguishes a subcommand from an operand without a per-command
|
|
262
|
+
* grammar. The lexicon IS that grammar, per reviewed row; anything it does not list (a bare verb, an
|
|
263
|
+
* interpreter head, an unreviewed subcommand, a runtime-defined name) yields no prefix, with no
|
|
264
|
+
* fallback. Naive spacing note: `folded` keeps quoted whitespace, so splitting on single spaces can
|
|
265
|
+
* shear a quoted segment — harmless in this direction, because the sheared pieces carry quote
|
|
266
|
+
* characters and can never equal a bare lexicon word; every suspicious shape lands on "no prefix".
|
|
267
|
+
*
|
|
268
|
+
* Every produced candidate must survive the round trip — parse as a rule AND admit the very command
|
|
269
|
+
* it was minted from. True by construction (a word-boundary lexicon prefix of a folded simple command
|
|
270
|
+
* is exactly the matcher's two arms); enforced anyway, fail-closed: a candidate that would not
|
|
271
|
+
* round-trip is silently not offered, since offering an option redemption would refuse is worse than
|
|
272
|
+
* offering one fewer.
|
|
192
273
|
*
|
|
193
274
|
* Returns an empty array for anything the rule lane cannot speak for (compounds, redirections,
|
|
194
275
|
* substitutions) — the card then simply carries no "don't ask again" option, which is the honest answer.
|