@manyoars/oars 0.1.1 → 0.3.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/README.md +134 -26
- package/dist/api.js +1 -1
- package/dist/bin.js +6 -29
- package/dist/cli.d.ts +6 -3
- package/dist/cli.js +370 -72
- package/dist/commands/keys.d.ts +31 -0
- package/dist/commands/keys.js +65 -0
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.js +3 -5
- package/dist/errors.d.ts +13 -13
- package/dist/errors.js +28 -11
- package/dist/gate/checks.d.ts +68 -0
- package/dist/gate/checks.js +584 -0
- package/dist/gate/command.d.ts +14 -0
- package/dist/gate/command.js +61 -0
- package/dist/gate/hook-fill-main.d.ts +1 -0
- package/dist/gate/hook-fill-main.js +16 -0
- package/dist/gate/hook-fill.d.ts +38 -0
- package/dist/gate/hook-fill.js +94 -0
- package/dist/gate/hook-main.d.ts +1 -0
- package/dist/gate/hook-main.js +35 -0
- package/dist/gate/hook-process.d.ts +14 -0
- package/dist/gate/hook-process.js +64 -0
- package/dist/gate/hook.d.ts +46 -0
- package/dist/gate/hook.js +256 -0
- package/dist/gate/install.d.ts +25 -0
- package/dist/gate/install.js +126 -0
- package/dist/gate/local-bin.d.ts +8 -0
- package/dist/gate/local-bin.js +36 -0
- package/dist/gate/notes.d.ts +19 -0
- package/dist/gate/notes.js +27 -0
- package/dist/gate/novelty.d.ts +35 -0
- package/dist/gate/novelty.js +99 -0
- package/dist/gate/policy.d.ts +25 -0
- package/dist/gate/policy.js +73 -0
- package/dist/gate/record.d.ts +120 -0
- package/dist/gate/record.js +125 -0
- package/dist/gate/registry-state.d.ts +10 -0
- package/dist/gate/registry-state.js +30 -0
- package/dist/gate/registry.d.ts +56 -0
- package/dist/gate/registry.js +94 -0
- package/dist/gate/replay.d.ts +20 -0
- package/dist/gate/replay.js +257 -0
- package/dist/gate/report.d.ts +50 -0
- package/dist/gate/report.js +89 -0
- package/dist/gate/scope.d.ts +7 -0
- package/dist/gate/scope.js +27 -0
- package/dist/gate/transcripts.d.ts +68 -0
- package/dist/gate/transcripts.js +232 -0
- package/dist/hook-bin.d.ts +1 -0
- package/dist/hook-bin.js +25 -0
- package/dist/issuer.d.ts +16 -0
- package/dist/issuer.js +55 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +64 -0
- package/dist/oidc.js +1 -1
- package/dist/sessions/actions.d.ts +35 -0
- package/dist/sessions/actions.js +351 -0
- package/dist/sessions/allow.d.ts +14 -0
- package/dist/sessions/allow.js +51 -0
- package/dist/sessions/archive.d.ts +11 -0
- package/dist/sessions/archive.js +38 -0
- package/dist/sessions/audit.d.ts +42 -0
- package/dist/sessions/audit.js +104 -0
- package/dist/sessions/baseline.d.ts +62 -0
- package/dist/sessions/baseline.js +188 -0
- package/dist/sessions/capture-runtime.d.ts +10 -0
- package/dist/sessions/capture-runtime.js +12 -0
- package/dist/sessions/cell-increments.d.ts +43 -0
- package/dist/sessions/cell-increments.js +209 -0
- package/dist/sessions/cells.d.ts +79 -0
- package/dist/sessions/cells.js +181 -0
- package/dist/sessions/codex.d.ts +53 -0
- package/dist/sessions/codex.js +679 -0
- package/dist/sessions/command.d.ts +117 -0
- package/dist/sessions/command.js +373 -0
- package/dist/sessions/copilot.d.ts +40 -0
- package/dist/sessions/copilot.js +432 -0
- package/dist/sessions/cursor.d.ts +24 -0
- package/dist/sessions/cursor.js +324 -0
- package/dist/sessions/derive.d.ts +57 -0
- package/dist/sessions/derive.js +320 -0
- package/dist/sessions/exclusion.d.ts +81 -0
- package/dist/sessions/exclusion.js +353 -0
- package/dist/sessions/extract.d.ts +179 -0
- package/dist/sessions/extract.js +1774 -0
- package/dist/sessions/fold.d.ts +17 -0
- package/dist/sessions/fold.js +71 -0
- package/dist/sessions/frame.d.ts +24 -0
- package/dist/sessions/frame.js +95 -0
- package/dist/sessions/gemini.d.ts +37 -0
- package/dist/sessions/gemini.js +613 -0
- package/dist/sessions/gold-arms.d.ts +67 -0
- package/dist/sessions/gold-arms.js +172 -0
- package/dist/sessions/gold.d.ts +197 -0
- package/dist/sessions/gold.js +463 -0
- package/dist/sessions/graph.d.ts +12 -0
- package/dist/sessions/graph.js +34 -0
- package/dist/sessions/grok.d.ts +36 -0
- package/dist/sessions/grok.js +421 -0
- package/dist/sessions/hook-calls.d.ts +3 -0
- package/dist/sessions/hook-calls.js +47 -0
- package/dist/sessions/hook-index.d.ts +53 -0
- package/dist/sessions/hook-index.js +157 -0
- package/dist/sessions/hooks.d.ts +13 -0
- package/dist/sessions/hooks.js +170 -0
- package/dist/sessions/identity.d.ts +22 -0
- package/dist/sessions/identity.js +101 -0
- package/dist/sessions/install.d.ts +54 -0
- package/dist/sessions/install.js +201 -0
- package/dist/sessions/lineage.d.ts +19 -0
- package/dist/sessions/lineage.js +118 -0
- package/dist/sessions/mcp.d.ts +6 -0
- package/dist/sessions/mcp.js +153 -0
- package/dist/sessions/merkle.d.ts +7 -0
- package/dist/sessions/merkle.js +28 -0
- package/dist/sessions/motif.d.ts +43 -0
- package/dist/sessions/motif.js +59 -0
- package/dist/sessions/package-resolve.d.ts +30 -0
- package/dist/sessions/package-resolve.js +183 -0
- package/dist/sessions/pending.d.ts +34 -0
- package/dist/sessions/pending.js +142 -0
- package/dist/sessions/pool-command.d.ts +44 -0
- package/dist/sessions/pool-command.js +542 -0
- package/dist/sessions/pool-delta.d.ts +54 -0
- package/dist/sessions/pool-delta.js +190 -0
- package/dist/sessions/pool-outbox.d.ts +63 -0
- package/dist/sessions/pool-outbox.js +260 -0
- package/dist/sessions/pool-print.d.ts +26 -0
- package/dist/sessions/pool-print.js +68 -0
- package/dist/sessions/pool-wire.d.ts +225 -0
- package/dist/sessions/pool-wire.js +620 -0
- package/dist/sessions/pool.d.ts +32 -0
- package/dist/sessions/pool.js +164 -0
- package/dist/sessions/projection.d.ts +75 -0
- package/dist/sessions/projection.js +165 -0
- package/dist/sessions/public-vocabulary.d.ts +73 -0
- package/dist/sessions/public-vocabulary.js +302 -0
- package/dist/sessions/query.d.ts +111 -0
- package/dist/sessions/query.js +246 -0
- package/dist/sessions/read-adoption.d.ts +2 -0
- package/dist/sessions/read-adoption.js +27 -0
- package/dist/sessions/read-call-shape.d.ts +2 -0
- package/dist/sessions/read-call-shape.js +25 -0
- package/dist/sessions/read-change.d.ts +2 -0
- package/dist/sessions/read-change.js +30 -0
- package/dist/sessions/read-cost.d.ts +4 -0
- package/dist/sessions/read-cost.js +37 -0
- package/dist/sessions/read-day-move.d.ts +3 -0
- package/dist/sessions/read-day-move.js +65 -0
- package/dist/sessions/read-fit.d.ts +7 -0
- package/dist/sessions/read-fit.js +41 -0
- package/dist/sessions/read-interaction.d.ts +5 -0
- package/dist/sessions/read-interaction.js +62 -0
- package/dist/sessions/read-lead-lag.d.ts +15 -0
- package/dist/sessions/read-lead-lag.js +81 -0
- package/dist/sessions/read-live.d.ts +6 -0
- package/dist/sessions/read-live.js +38 -0
- package/dist/sessions/read-method.d.ts +2 -0
- package/dist/sessions/read-method.js +29 -0
- package/dist/sessions/read-next-step.d.ts +2 -0
- package/dist/sessions/read-next-step.js +35 -0
- package/dist/sessions/read-order.d.ts +54 -0
- package/dist/sessions/read-order.js +62 -0
- package/dist/sessions/read-readable.d.ts +8 -0
- package/dist/sessions/read-readable.js +24 -0
- package/dist/sessions/read-recovery.d.ts +20 -0
- package/dist/sessions/read-recovery.js +97 -0
- package/dist/sessions/read-rows.d.ts +13 -0
- package/dist/sessions/read-rows.js +47 -0
- package/dist/sessions/read-substitution.d.ts +5 -0
- package/dist/sessions/read-substitution.js +51 -0
- package/dist/sessions/read-support.d.ts +48 -0
- package/dist/sessions/read-support.js +73 -0
- package/dist/sessions/read-tool-day.d.ts +2 -0
- package/dist/sessions/read-tool-day.js +26 -0
- package/dist/sessions/read-version-boundary.d.ts +31 -0
- package/dist/sessions/read-version-boundary.js +95 -0
- package/dist/sessions/read.d.ts +79 -0
- package/dist/sessions/read.js +84 -0
- package/dist/sessions/recall-consequence.d.ts +45 -0
- package/dist/sessions/recall-consequence.js +179 -0
- package/dist/sessions/recall-object.d.ts +137 -0
- package/dist/sessions/recall-object.js +77 -0
- package/dist/sessions/recall-pool.d.ts +19 -0
- package/dist/sessions/recall-pool.js +96 -0
- package/dist/sessions/recall-push.d.ts +26 -0
- package/dist/sessions/recall-push.js +95 -0
- package/dist/sessions/recall-reads.d.ts +117 -0
- package/dist/sessions/recall-reads.js +410 -0
- package/dist/sessions/recall-render.d.ts +19 -0
- package/dist/sessions/recall-render.js +168 -0
- package/dist/sessions/redact.d.ts +6 -0
- package/dist/sessions/redact.js +54 -0
- package/dist/sessions/repo-root.d.ts +10 -0
- package/dist/sessions/repo-root.js +101 -0
- package/dist/sessions/schema.d.ts +644 -0
- package/dist/sessions/schema.js +148 -0
- package/dist/sessions/session-shape.d.ts +73 -0
- package/dist/sessions/session-shape.js +342 -0
- package/dist/sessions/sources.d.ts +50 -0
- package/dist/sessions/sources.js +159 -0
- package/dist/sessions/steps.d.ts +105 -0
- package/dist/sessions/steps.js +250 -0
- package/dist/sessions/store.d.ts +297 -0
- package/dist/sessions/store.js +1478 -0
- package/dist/sessions/strata.d.ts +26 -0
- package/dist/sessions/strata.js +77 -0
- package/dist/sessions/survival.d.ts +25 -0
- package/dist/sessions/survival.js +230 -0
- package/dist/sessions/verify.d.ts +53 -0
- package/dist/sessions/verify.js +286 -0
- package/dist/sessions/vocab-load.d.ts +26 -0
- package/dist/sessions/vocab-load.js +61 -0
- package/dist/sessions/vscode-copilot.d.ts +38 -0
- package/dist/sessions/vscode-copilot.js +477 -0
- package/package.json +4 -5
- package/vocab/crates.json +1 -0
- package/vocab/go.json +1 -0
- package/vocab/harness.json +1 -0
- package/vocab/homebrew.json +1 -0
- package/vocab/manifest.json +1 -0
- package/vocab/maven.json +1 -0
- package/vocab/models.json +1 -0
- package/vocab/npm.json +1 -0
- package/vocab/programs.json +1 -0
- package/vocab/pypi.json +1 -0
- package/vocab/rubygems.json +1 -0
- package/dist/commands/deploy-input.d.ts +0 -35
- package/dist/commands/deploy-input.js +0 -107
- package/dist/commands/deploy.d.ts +0 -22
- package/dist/commands/deploy.js +0 -120
- package/dist/commands/status.d.ts +0 -7
- package/dist/commands/status.js +0 -18
- package/dist/commands/test-charge.d.ts +0 -11
- package/dist/commands/test-charge.js +0 -34
- package/dist/journal.d.ts +0 -26
- package/dist/journal.js +0 -58
- package/parity/deploy-plans/paid-api.json +0 -34
- package/parity/deploy-plans/per-task-agent.json +0 -34
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The `oars gate` command group's bodies: each returns the `--json` document and the text a human sees.
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
4
|
+
import { crossingsPath } from './hook.js';
|
|
5
|
+
import { COMMAND, EVENT, installGateHook, MATCHER, uninstallGateHook } from './install.js';
|
|
6
|
+
import { loadIndex } from './novelty.js';
|
|
7
|
+
import { formatPolicy, loadPolicy, policyPath } from './policy.js';
|
|
8
|
+
import { parseRows } from './record.js';
|
|
9
|
+
import { formatReport, reportOf } from './report.js';
|
|
10
|
+
import { crossingsFile, replay } from './replay.js';
|
|
11
|
+
const runtimeOf = (deps) => {
|
|
12
|
+
if (deps.sessions === undefined)
|
|
13
|
+
throw new CliError('The session store is not configured in this run.', ExitCode.UNEXPECTED, 'unexpected');
|
|
14
|
+
return deps.sessions;
|
|
15
|
+
};
|
|
16
|
+
/** Reads local logs only; a `registry` reader is passed solely by `--registry-state`. */
|
|
17
|
+
export const gateReplay = async (deps, options) => {
|
|
18
|
+
const done = await replay(runtimeOf(deps), { allow: options.allow, now: deps.now, ...(options.registry === undefined ? {} : { registry: options.registry }) });
|
|
19
|
+
return { document: { ok: true, run: done.run, file: done.file }, text: done.text };
|
|
20
|
+
};
|
|
21
|
+
export const gateReport = async (deps) => {
|
|
22
|
+
const text = await readFile(crossingsFile(runtimeOf(deps).storeDir), 'utf8').catch(() => '');
|
|
23
|
+
const rows = parseRows(text);
|
|
24
|
+
if (!rows.some((row) => row.kind === 'run' || row.kind === 'crossing')) {
|
|
25
|
+
throw new CliError('No crossings have been recorded yet: run `oars gate install` to record them as they happen, or `oars gate replay --allow <folder>` to find the ones already in your logs.', ExitCode.USAGE, 'usage');
|
|
26
|
+
}
|
|
27
|
+
const report = reportOf(rows);
|
|
28
|
+
return { document: report, text: formatReport(report) };
|
|
29
|
+
};
|
|
30
|
+
const installOf = (deps) => {
|
|
31
|
+
if (deps.install === undefined)
|
|
32
|
+
throw new CliError('Hook install is not configured in this run.', ExitCode.UNEXPECTED, 'unexpected');
|
|
33
|
+
return deps.install;
|
|
34
|
+
};
|
|
35
|
+
/** User scope only: the one place the harness reads for every project, and the one place this command ever writes. */
|
|
36
|
+
export const gateInstall = async (deps) => {
|
|
37
|
+
const file = installOf(deps).userSettings;
|
|
38
|
+
const result = await installGateHook(file);
|
|
39
|
+
const lines = [
|
|
40
|
+
result.changed
|
|
41
|
+
? `Installed the gate hook in ${file}: ${EVENT} for ${MATCHER}, running \`${COMMAND}\`. It records each crossing in crossings.jsonl, adds a note about a young or missing name by default, and denies a call only if you write \`block\` in gate-policy.json; it never allows or changes a call.`
|
|
42
|
+
: `The gate hook is already installed in ${file}; nothing was changed.`,
|
|
43
|
+
];
|
|
44
|
+
// A record with no kept index cannot tell what is new without a scan the hook must not do, so it reads `not-read` until a replay seeds the index.
|
|
45
|
+
const storeDir = deps.sessions?.storeDir;
|
|
46
|
+
if (storeDir !== undefined && (await loadIndex(storeDir, crossingsPath(storeDir))).state === 'unseeded') {
|
|
47
|
+
lines.push('Run `oars gate replay --allow <folder>` once to seed the novelty index; until then the hook records novelty as not-read and shows no note.');
|
|
48
|
+
}
|
|
49
|
+
return { document: { ok: true, file, changed: result.changed, event: EVENT, matcher: MATCHER, command: COMMAND }, text: lines.join('\n') };
|
|
50
|
+
};
|
|
51
|
+
/** The policy in force for this store: the file's settings, or the defaults and why. Reads one file, writes none. */
|
|
52
|
+
export const gatePolicy = async (deps) => {
|
|
53
|
+
const storeDir = runtimeOf(deps).storeDir;
|
|
54
|
+
const read = await loadPolicy(storeDir);
|
|
55
|
+
return { document: { ok: true, file: policyPath(storeDir), source: read.source, policy: read.policy }, text: formatPolicy(read, policyPath(storeDir)) };
|
|
56
|
+
};
|
|
57
|
+
export const gateUninstall = async (deps) => {
|
|
58
|
+
const result = await uninstallGateHook(installOf(deps).userSettings);
|
|
59
|
+
const text = result.removed ? `Removed the gate hook from ${result.file}; other settings and hooks are untouched, and crossings.jsonl stays.` : `No gate hook in ${result.file}.`;
|
|
60
|
+
return { document: { ok: true, ...result }, text };
|
|
61
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The entry the hook starts, detached, after a cut ask: `node hook-fill-main.js <storeDir> <class> <name> <cwd>`. It prints nothing, answers nothing, and exits 0 whatever
|
|
2
|
+
// happens; nobody waits on it.
|
|
3
|
+
import { CROSSING_CLASSES } from './checks.js';
|
|
4
|
+
import { FILL_TIMEOUT_MS, runFill } from './hook-fill.js';
|
|
5
|
+
import { managerReader } from './registry.js';
|
|
6
|
+
const [storeDir, cls, name, cwd] = process.argv.slice(2);
|
|
7
|
+
const known = CROSSING_CLASSES.find((one) => one === cls);
|
|
8
|
+
try {
|
|
9
|
+
if (storeDir !== undefined && known !== undefined && name !== undefined && cwd !== undefined) {
|
|
10
|
+
await runFill(storeDir, { class: known, name, cwd }, { registry: managerReader(undefined, FILL_TIMEOUT_MS), now: Date.now });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// Fail-open: an answer that cannot be kept is asked about again.
|
|
15
|
+
}
|
|
16
|
+
process.exit(0);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CrossingClass } from './checks.js';
|
|
2
|
+
import { type RegistryReader } from './registry.js';
|
|
3
|
+
export interface FillAsk {
|
|
4
|
+
class: CrossingClass;
|
|
5
|
+
name: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
}
|
|
8
|
+
/** The helper gives the manager this long. A lock younger than five minutes keeps a second helper from starting, whether the first is still asking or has failed. */
|
|
9
|
+
export declare const FILL_TIMEOUT_MS = 15000;
|
|
10
|
+
export declare const FILL_LOCK_MS = 300000;
|
|
11
|
+
/** Whether a helper holds the name now: the lock is there and younger than `FILL_LOCK_MS`. The hook asks nothing of the manager itself while it is, so one name has one ask. */
|
|
12
|
+
export declare const isLocked: (storeDir: string, ask: Pick<FillAsk, "class" | "name">, now: number) => Promise<boolean>;
|
|
13
|
+
/**
|
|
14
|
+
* Takes the lock back: a helper that never started will never release it, and the name would go unasked until `FILL_LOCK_MS` passed. Synchronous, like `create`, so a deadline's
|
|
15
|
+
* `exit(0)` cannot end the process between the failed start and the removal.
|
|
16
|
+
*/
|
|
17
|
+
export declare const releaseLock: (storeDir: string, ask: Pick<FillAsk, "class" | "name">) => void;
|
|
18
|
+
/** The part of `child_process.spawn` the helper start uses, so a test can hand in a child that fails to start. */
|
|
19
|
+
export type HelperSpawn = (command: string, args: string[], options: {
|
|
20
|
+
detached: true;
|
|
21
|
+
stdio: 'ignore';
|
|
22
|
+
windowsHide: true;
|
|
23
|
+
}) => {
|
|
24
|
+
on: (event: 'error', listener: () => void) => unknown;
|
|
25
|
+
unref: () => void;
|
|
26
|
+
};
|
|
27
|
+
/** The helper outlives the hook: detached, no stdio, never waited on, so the harness's call does not wait for it either. One that cannot start never releases its lock, so the lock goes on its `error`. */
|
|
28
|
+
export declare const startHelper: (spawn: HelperSpawn, storeDir: string, ask: FillAsk) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Called by the hook after a cut ask: takes the lock and starts the helper. Nothing here throws into the hook: a store that cannot be written, or a helper that cannot
|
|
31
|
+
* start, is an answer that does not come. The lock goes when the start throws, so the next sight asks about the name again.
|
|
32
|
+
*/
|
|
33
|
+
export declare const startFill: (storeDir: string, ask: FillAsk, now: number, start: (storeDir: string, ask: FillAsk) => void) => Promise<void>;
|
|
34
|
+
/** The helper's work: ask, and keep a `found` or `not-found` answer in the cache. The lock goes once an answer is kept; after a failure it stays, so a down registry is asked about at most every five minutes. */
|
|
35
|
+
export declare const runFill: (storeDir: string, ask: FillAsk, deps: {
|
|
36
|
+
registry: RegistryReader;
|
|
37
|
+
now: () => number;
|
|
38
|
+
}) => Promise<void>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The fill: a registry ask the hook had to cut is finished by a helper process that outlives the hook and writes the answer to the hook's cache, so the next sight of the
|
|
2
|
+
// name reads it inside the budget. The helper calls the installed manager as the hook does (`managerReader`), contacts nothing the manager would not, and a failed or
|
|
3
|
+
// unreachable ask writes nothing. One ask per name at a time: a lock file in the store, dated by its mtime, is taken before the helper starts.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { appendFile, mkdir, rm, stat } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { GATE_VERSION, folderKey } from './record.js';
|
|
10
|
+
import { askable } from './registry.js';
|
|
11
|
+
/** The helper gives the manager this long. A lock younger than five minutes keeps a second helper from starting, whether the first is still asking or has failed. */
|
|
12
|
+
export const FILL_TIMEOUT_MS = 15_000;
|
|
13
|
+
export const FILL_LOCK_MS = 300_000;
|
|
14
|
+
const lockPath = (storeDir, ask) => join(storeDir, `gate-ask-${createHash('sha256').update(`${ask.class}\0${ask.name}`).digest('hex').slice(0, 16)}.lock`);
|
|
15
|
+
/**
|
|
16
|
+
* Creates the lock, failing if it exists: of two hooks racing for the name, one wins. It is synchronous so the lock never exists across a turn of the event loop before the
|
|
17
|
+
* helper starts: a deadline timer firing in such a turn would end the hook with a lock held and nothing behind it.
|
|
18
|
+
*/
|
|
19
|
+
const create = (path) => {
|
|
20
|
+
try {
|
|
21
|
+
writeFileSync(path, '', { mode: 0o600, flag: 'wx' });
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/** Takes the name's lock. False when a younger one is held; an older one is a helper that died, and is taken over. */
|
|
29
|
+
const takeLock = async (path, now) => {
|
|
30
|
+
if (create(path))
|
|
31
|
+
return true;
|
|
32
|
+
const held = await stat(path).then((found) => found.mtimeMs, () => now);
|
|
33
|
+
if (now - held < FILL_LOCK_MS)
|
|
34
|
+
return false;
|
|
35
|
+
await rm(path, { force: true });
|
|
36
|
+
return create(path);
|
|
37
|
+
};
|
|
38
|
+
/** Whether a helper holds the name now: the lock is there and younger than `FILL_LOCK_MS`. The hook asks nothing of the manager itself while it is, so one name has one ask. */
|
|
39
|
+
export const isLocked = async (storeDir, ask, now) => {
|
|
40
|
+
const held = await stat(lockPath(storeDir, ask)).then((found) => found.mtimeMs, () => undefined);
|
|
41
|
+
return held !== undefined && now - held < FILL_LOCK_MS;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Takes the lock back: a helper that never started will never release it, and the name would go unasked until `FILL_LOCK_MS` passed. Synchronous, like `create`, so a deadline's
|
|
45
|
+
* `exit(0)` cannot end the process between the failed start and the removal.
|
|
46
|
+
*/
|
|
47
|
+
export const releaseLock = (storeDir, ask) => {
|
|
48
|
+
try {
|
|
49
|
+
rmSync(lockPath(storeDir, ask), { force: true });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Fail-open: a lock that stays is only a name left unasked until it ages out.
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/** The helper outlives the hook: detached, no stdio, never waited on, so the harness's call does not wait for it either. One that cannot start never releases its lock, so the lock goes on its `error`. */
|
|
56
|
+
export const startHelper = (spawn, storeDir, ask) => {
|
|
57
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./hook-fill-main.js', import.meta.url)), storeDir, ask.class, ask.name, ask.cwd], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
58
|
+
child.on('error', () => {
|
|
59
|
+
releaseLock(storeDir, ask);
|
|
60
|
+
});
|
|
61
|
+
child.unref();
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Called by the hook after a cut ask: takes the lock and starts the helper. Nothing here throws into the hook: a store that cannot be written, or a helper that cannot
|
|
65
|
+
* start, is an answer that does not come. The lock goes when the start throws, so the next sight asks about the name again.
|
|
66
|
+
*/
|
|
67
|
+
export const startFill = async (storeDir, ask, now, start) => {
|
|
68
|
+
try {
|
|
69
|
+
await mkdir(storeDir, { recursive: true });
|
|
70
|
+
if (!(await takeLock(lockPath(storeDir, ask), now)))
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
start(storeDir, ask);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
releaseLock(storeDir, ask);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Fail-open.
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/** The helper's work: ask, and keep a `found` or `not-found` answer in the cache. The lock goes once an answer is kept; after a failure it stays, so a down registry is asked about at most every five minutes. */
|
|
84
|
+
export const runFill = async (storeDir, ask, deps) => {
|
|
85
|
+
if (!askable(ask.class, ask.name))
|
|
86
|
+
return;
|
|
87
|
+
const answer = await deps.registry(ask);
|
|
88
|
+
if (answer.state === 'unreachable')
|
|
89
|
+
return;
|
|
90
|
+
const at = new Date(deps.now()).toISOString();
|
|
91
|
+
const row = { kind: 'registry', v: GATE_VERSION, day: at.slice(0, 10), ts: at, folder: folderKey(ask.cwd), class: ask.class, state: answer.state, created: answer.created, name: ask.name, latest: answer.latest };
|
|
92
|
+
await appendFile(join(storeDir, 'gate-registry.jsonl'), `${JSON.stringify(row)}\n`, { mode: 0o600 });
|
|
93
|
+
await rm(lockPath(storeDir, ask), { force: true });
|
|
94
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The entry for `oars gate hook`: it imports the hook and the little it needs, never the CLI. Everything it does is in `hook-process.ts`, which a test can drive; this file
|
|
2
|
+
// only wires the real process into it, and every line of the wiring sits inside the guard that exits 0.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { performance } from 'node:perf_hooks';
|
|
7
|
+
import { defaultConfigDir } from '../credentials.js';
|
|
8
|
+
import { DEADLINE_MS } from './hook.js';
|
|
9
|
+
import { isLocked, startFill, startHelper } from './hook-fill.js';
|
|
10
|
+
import { runHookProcess } from './hook-process.js';
|
|
11
|
+
import { managerReader } from './registry.js';
|
|
12
|
+
/** The process itself ends this long after `DEADLINE_MS`. */
|
|
13
|
+
const GRACE_MS = 100;
|
|
14
|
+
const readStdin = async () => {
|
|
15
|
+
process.stdin.setEncoding('utf8');
|
|
16
|
+
let text = '';
|
|
17
|
+
for await (const chunk of process.stdin)
|
|
18
|
+
text += String(chunk);
|
|
19
|
+
return text;
|
|
20
|
+
};
|
|
21
|
+
const depsFor = (stage) => {
|
|
22
|
+
const storeDir = join(defaultConfigDir(process.env, process.platform, homedir()), 'oars', 'sessions');
|
|
23
|
+
return {
|
|
24
|
+
storeDir,
|
|
25
|
+
now: Date.now,
|
|
26
|
+
// `performance.now()` counts from the start of the process, so node's own start is inside the budget.
|
|
27
|
+
clock: () => performance.now(),
|
|
28
|
+
deadlineMs: DEADLINE_MS,
|
|
29
|
+
registry: managerReader(undefined, DEADLINE_MS),
|
|
30
|
+
fill: (ask) => startFill(storeDir, ask, Date.now(), (dir, one) => { startHelper(spawn, dir, one); }),
|
|
31
|
+
locked: (ask) => isLocked(storeDir, ask, Date.now()),
|
|
32
|
+
stage,
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
await runHookProcess({ stdin: readStdin, deps: depsFor, exit: (code) => process.exit(code), write: (text) => new Promise((done) => process.stdout.write(text, () => { done(); })), deadlineMs: DEADLINE_MS + GRACE_MS - performance.now() });
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type GateHookDeps } from './hook.js';
|
|
2
|
+
export interface HookProcess {
|
|
3
|
+
/** Reads the whole of stdin. Called inside the guard, so a stream that cannot be set up is a failed read, not a crash. */
|
|
4
|
+
stdin: () => Promise<string>;
|
|
5
|
+
/** Builds the hook's dependencies, handed the function that records what is about to be written. Called inside the guard. */
|
|
6
|
+
deps: (stage: NonNullable<GateHookDeps['stage']>) => GateHookDeps;
|
|
7
|
+
exit: (code: number) => void;
|
|
8
|
+
/** Writes the hook's answer to stdout and resolves once it is flushed. Absent: nothing is written. */
|
|
9
|
+
write?: (text: string) => Promise<void>;
|
|
10
|
+
/** Milliseconds from now until the process ends itself. */
|
|
11
|
+
deadlineMs: number;
|
|
12
|
+
}
|
|
13
|
+
/** Resolves once `exit(0)` has been called, or never when the deadline timer is what ended the process. */
|
|
14
|
+
export declare const runHookProcess: ({ stdin, deps, exit, write, deadlineMs }: HookProcess) => Promise<void>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The process behind `oars gate hook`, with every side effect handed in so a test can drive it: stdin in, at most one JSON document out, `exit(0)` whatever happens. A hard deadline ends
|
|
2
|
+
// the process before the harness's own, writing the record it has staged if the write had not finished. Nothing here may throw past `runHookProcess`: exit 1 from a
|
|
3
|
+
// PreToolUse hook is a visible error and exit 2 blocks the tool call, and the hook must never cost a session either.
|
|
4
|
+
import { appendFileSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { gateHook } from './hook.js';
|
|
6
|
+
import { parseRows } from './record.js';
|
|
7
|
+
/** The staged record is written before anything prints, so when the deadline ends the process with it still staged no note or deny was shown: its crossing rows read as no exposure, since a row cannot be backfilled. A block row has `arm: none` already but names its line, so every crossing row is reset, not only those with an arm. */
|
|
8
|
+
const unprinted = (text) => parseRows(text)
|
|
9
|
+
.map((row) => `${JSON.stringify(row.kind === 'crossing' ? { ...row, arm: 'none', lineShown: 'none', lineHeld: 'none' } : row)}\n`)
|
|
10
|
+
.join('');
|
|
11
|
+
const sizeOf = (file) => {
|
|
12
|
+
try {
|
|
13
|
+
return statSync(file).size;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/** Whether the record's own bytes are in the file past the length it had when the record was staged. Another hook's row may have grown the file too, so growth alone proves nothing. */
|
|
20
|
+
const landed = (file, text, size) => {
|
|
21
|
+
try {
|
|
22
|
+
return readFileSync(file).subarray(size).includes(text);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/** Resolves once `exit(0)` has been called, or never when the deadline timer is what ended the process. */
|
|
29
|
+
export const runHookProcess = async ({ stdin, deps, exit, write, deadlineMs }) => {
|
|
30
|
+
// `size` is the file's length when the record was staged: an append that wrote and then stalled has put the record's own bytes past it, and the deadline must not write it again.
|
|
31
|
+
let staged = null;
|
|
32
|
+
// The last resort when the process is stuck past its grace: keep the staged record, then go. A write that fails here has nowhere to report, so it is dropped.
|
|
33
|
+
setTimeout(() => {
|
|
34
|
+
// The row first: it is the record this timer exists to keep, and the stop can be slow (on Windows it is a `taskkill` spawn at a moment already past the deadline).
|
|
35
|
+
try {
|
|
36
|
+
if (staged !== null && !landed(staged.file, staged.text, staged.size))
|
|
37
|
+
appendFileSync(staged.file, unprinted(staged.text), { mode: 0o600 });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Fail-open.
|
|
41
|
+
}
|
|
42
|
+
// A registry ask still running is then stopped, so the manager the hook started does not outlive it; a stop that throws must not cost the exit.
|
|
43
|
+
try {
|
|
44
|
+
staged?.stop?.();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Fail-open.
|
|
48
|
+
}
|
|
49
|
+
exit(0);
|
|
50
|
+
}, Math.max(deadlineMs, 0)).unref();
|
|
51
|
+
try {
|
|
52
|
+
const built = deps((pending) => {
|
|
53
|
+
staged = pending === null ? null : { ...pending, size: sizeOf(pending.file) };
|
|
54
|
+
});
|
|
55
|
+
const output = await gateHook(await stdin(), built);
|
|
56
|
+
if (output !== '')
|
|
57
|
+
await write?.(output);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Fail-open: a throw anywhere above is a record not written, never an exit code.
|
|
61
|
+
}
|
|
62
|
+
// A manager the registry ask started can outlive the ask; leaving by `exit` rather than waiting for it is what keeps the call under a second.
|
|
63
|
+
exit(0);
|
|
64
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { FillAsk } from './hook-fill.js';
|
|
2
|
+
import { type RegistryReader } from './registry.js';
|
|
3
|
+
/** Where the crossings go. Named here, not imported from `replay.ts`, which reads transcripts and would be loaded for nothing. */
|
|
4
|
+
export declare const crossingsPath: (storeDir: string) => string;
|
|
5
|
+
/** The hook's kept registry answers, small and its own: reading the record for them would scan it. */
|
|
6
|
+
export declare const registryCachePath: (storeDir: string) => string;
|
|
7
|
+
/** The registry ask is cut at this many ms on the process clock, less a write reserve; `hook-main.ts` ends the process a short grace after it, so the whole call is under a second. */
|
|
8
|
+
export declare const DEADLINE_MS = 800;
|
|
9
|
+
/** A registry answer the hook kept is used for this many days. A `latest` is read "as of the crossing", so an older one is asked again. */
|
|
10
|
+
export declare const HOOK_REGISTRY_DAYS = 1;
|
|
11
|
+
/** A not-found answer is trusted this long: a name can be published any minute, and a block rests on the answer. */
|
|
12
|
+
export declare const NOT_FOUND_MS = 600000;
|
|
13
|
+
export interface GateHookDeps {
|
|
14
|
+
storeDir: string;
|
|
15
|
+
now: () => number;
|
|
16
|
+
/** The process clock: ms since the process started in the entry, so the deadline counts node's own start. */
|
|
17
|
+
clock: () => number;
|
|
18
|
+
/** The whole hook ends by this many ms on `clock`. */
|
|
19
|
+
deadlineMs: number;
|
|
20
|
+
/** The draw for the holdback, in [0, 1). Absent: `Math.random`. */
|
|
21
|
+
random?: () => number;
|
|
22
|
+
/** Asks the package manager. Absent: no question is ever asked. */
|
|
23
|
+
registry?: RegistryReader;
|
|
24
|
+
/** Appends text to a file. Replaceable so a full disk can be tried. */
|
|
25
|
+
append?: (file: string, text: string) => Promise<void>;
|
|
26
|
+
/** Called with the name whose ask was cut or had no time to start, so a helper can finish it and fill the cache for the next sight. Absent: nothing is finished. */
|
|
27
|
+
fill?: (ask: FillAsk) => Promise<void>;
|
|
28
|
+
/** Whether a helper already holds the name, which keeps the hook from asking the manager a second time. Absent: never locked. */
|
|
29
|
+
locked?: (ask: FillAsk) => Promise<boolean>;
|
|
30
|
+
/**
|
|
31
|
+
* Told what is about to be written (and `null` once it is), so a process that must stop at a hard deadline can write the record it has. While a registry ask is live the record
|
|
32
|
+
* is the rows so far plus a not-read row for the crossing asked about, with `stop` ending that ask: a process cut off mid-ask stops it and leaves one row, not a manager running unseen.
|
|
33
|
+
*/
|
|
34
|
+
stage?: (pending: {
|
|
35
|
+
file: string;
|
|
36
|
+
text: string;
|
|
37
|
+
stop?: () => void;
|
|
38
|
+
} | null) => void;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Records the crossings of one tool call. Resolves with the JSON to print: a note as context, or a deny labeled with the fact; never an allow or a changed call.
|
|
42
|
+
* Nothing is written, and no network is touched, for a call that crosses nothing.
|
|
43
|
+
*/
|
|
44
|
+
export declare const runGateHook: (stdin: string, deps: GateHookDeps) => Promise<string>;
|
|
45
|
+
/** What the hook writes to stdout: the JSON of a note or a deny, or '' when silent and on any failure, so the call runs as asked. */
|
|
46
|
+
export declare const gateHook: (stdin: string, deps: GateHookDeps) => Promise<string>;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// `oars gate hook`: the PreToolUse hook that records a crossing as it happens. It records, and says something only when the policy (`policy.ts`) says to: a note, or a deny labeled with a fact. It reads the call the harness hands it, runs the same
|
|
2
|
+
// `checkCall` replay runs, and appends one row per crossing to `crossings.jsonl`; it never allows a call and never changes its input, so the call
|
|
3
|
+
// runs as asked whatever happens here. Every failure is swallowed: malformed input, a missing store, a full disk, a throw. It imports only what it needs, because the
|
|
4
|
+
// harness waits on it before every matching tool call, and it ends before `deadlineMs` however slow the registry is.
|
|
5
|
+
import { appendFile, mkdir, readFile, stat } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { makeRedactor } from '../sessions/redact.js';
|
|
8
|
+
import { checkCall } from './checks.js';
|
|
9
|
+
import { withLocalBin } from './local-bin.js';
|
|
10
|
+
import { decide } from './notes.js';
|
|
11
|
+
import { loadPolicy } from './policy.js';
|
|
12
|
+
import { loadIndex, keepIndex, noveltyOf, scopesOf } from './novelty.js';
|
|
13
|
+
import { GATE_VERSION, folderKey, parseRows, permissionOf } from './record.js';
|
|
14
|
+
import { askable } from './registry.js';
|
|
15
|
+
import { ageOf, againstLatestOf, askedOf, DAY_MS } from './registry-state.js';
|
|
16
|
+
import { repositoryOfCwd } from './scope.js';
|
|
17
|
+
/** Where the crossings go. Named here, not imported from `replay.ts`, which reads transcripts and would be loaded for nothing. */
|
|
18
|
+
export const crossingsPath = (storeDir) => join(storeDir, 'crossings.jsonl');
|
|
19
|
+
/** The hook's kept registry answers, small and its own: reading the record for them would scan it. */
|
|
20
|
+
export const registryCachePath = (storeDir) => join(storeDir, 'gate-registry.jsonl');
|
|
21
|
+
/** The registry ask is cut at this many ms on the process clock, less a write reserve; `hook-main.ts` ends the process a short grace after it, so the whole call is under a second. */
|
|
22
|
+
export const DEADLINE_MS = 800;
|
|
23
|
+
/** A registry answer the hook kept is used for this many days. A `latest` is read "as of the crossing", so an older one is asked again. */
|
|
24
|
+
export const HOOK_REGISTRY_DAYS = 1;
|
|
25
|
+
/** A not-found answer is trusted this long: a name can be published any minute, and a block rests on the answer. */
|
|
26
|
+
export const NOT_FOUND_MS = 600_000;
|
|
27
|
+
/** The room a row's write is given after the registry ask has been cut off. */
|
|
28
|
+
const WRITE_RESERVE_MS = 150;
|
|
29
|
+
/** One call hands at most this many names to helpers; the rest wait for a later sight, so a call naming dozens cannot start dozens of processes. */
|
|
30
|
+
const MAX_HELPERS = 3;
|
|
31
|
+
/** An ask with less than this left is not started: it could not finish. */
|
|
32
|
+
const MIN_ASK_MS = 100;
|
|
33
|
+
const isInput = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
34
|
+
const text = (value) => (typeof value === 'string' && value !== '' ? value : null);
|
|
35
|
+
const parse = (stdin) => {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(stdin);
|
|
38
|
+
return isInput(parsed) ? parsed : {};
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const isFolder = (path) => stat(path).then((found) => found.isDirectory(), () => false);
|
|
45
|
+
/** The organization domains a deposit saved to mask, read only when a call crossed something. */
|
|
46
|
+
const redactDomains = async (storeDir) => {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(await readFile(join(storeDir, 'state.json'), 'utf8'));
|
|
49
|
+
const domains = isInput(parsed) ? parsed.redactDomains : undefined;
|
|
50
|
+
return Array.isArray(domains) ? domains.filter((domain) => typeof domain === 'string') : [];
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const keyOf = (crossing) => `${crossing.class}\0${crossing.name}`;
|
|
57
|
+
const keptAnswers = async (storeDir) => {
|
|
58
|
+
const body = await readFile(registryCachePath(storeDir), 'utf8').catch(() => '');
|
|
59
|
+
const kept = new Map();
|
|
60
|
+
for (const row of parseRows(body)) {
|
|
61
|
+
if (row.kind === 'registry')
|
|
62
|
+
kept.set(keyOf(row), row);
|
|
63
|
+
}
|
|
64
|
+
return kept;
|
|
65
|
+
};
|
|
66
|
+
/** A kept answer is dated by its UTC day, so "a day" is that day and the next: at most 48 hours old. Every kept answer is trusted only for the folder that asked, since its config can name another registry and a note says "the registry your package manager uses". A not-found answer is also cut short, since a name can be published any minute. */
|
|
67
|
+
const fresh = (row, now, folder) => row.folder === folder && (row.state === 'not-found' ? row.ts !== undefined && now - Date.parse(row.ts) < NOT_FOUND_MS : now - Date.parse(row.day) < (HOOK_REGISTRY_DAYS + 1) * DAY_MS);
|
|
68
|
+
const within = (promise, ms) => new Promise((resolve) => {
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
resolve(undefined);
|
|
71
|
+
}, Math.max(ms, 0));
|
|
72
|
+
promise.then((value) => {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
resolve(value);
|
|
75
|
+
}, () => {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
resolve(undefined);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
/** What is left for a registry ask on the process clock, less the room a row's write is given. */
|
|
81
|
+
const budgetOf = (deps) => deps.deadlineMs - WRITE_RESERVE_MS - deps.clock();
|
|
82
|
+
const asAnswer = (row) => ({ state: row.state, latest: row.latest, created: row.created });
|
|
83
|
+
/** The registry state at the moment: a kept answer first, else one bounded ask. `undefined` is "not read": not asked, not askable, or not answered in time. */
|
|
84
|
+
const registryAnswer = async (deps, crossing, cwd, kept, helpers, hold) => {
|
|
85
|
+
if (!askedOf(crossing) || crossing.versionForm === 'url' || !askable(crossing.class, crossing.name))
|
|
86
|
+
return undefined;
|
|
87
|
+
const held = kept.get(keyOf(crossing));
|
|
88
|
+
if (held !== undefined && cwd !== null && fresh(held, deps.now(), folderKey(cwd)))
|
|
89
|
+
return { answer: asAnswer(held), asked: false };
|
|
90
|
+
if (deps.registry === undefined || cwd === null || !(await isFolder(cwd)))
|
|
91
|
+
return undefined;
|
|
92
|
+
const ask = { class: crossing.class, name: crossing.name, cwd };
|
|
93
|
+
// A helper already asks for this name (or failed within the last five minutes): one ask per name, so this sight reads nothing.
|
|
94
|
+
if ((await deps.locked?.(ask).catch(() => false)) === true)
|
|
95
|
+
return undefined;
|
|
96
|
+
const stop = new AbortController();
|
|
97
|
+
let answer;
|
|
98
|
+
if (budgetOf(deps) >= MIN_ASK_MS) {
|
|
99
|
+
// The first sight on a fresh store has no folder yet, and the deadline's synchronous write needs one: made here, before the ask, so a cut-off hook can still leave its row.
|
|
100
|
+
await mkdir(deps.storeDir, { recursive: true }).catch(() => undefined);
|
|
101
|
+
hold(() => { stop.abort(); });
|
|
102
|
+
const pending = deps.registry({ ...ask, signal: stop.signal });
|
|
103
|
+
// Starting the manager is a blocking spawn that can take a good part of the budget, so the cut counts what is left now, not what was left before the call: read before it, the cut
|
|
104
|
+
// could land behind the process's own deadline, which ends the process with the ask still running.
|
|
105
|
+
answer = await within(pending, budgetOf(deps));
|
|
106
|
+
}
|
|
107
|
+
if (answer === undefined) {
|
|
108
|
+
// Not answered in time: the row reads not-read, and a helper finishes the ask so the next sight reads it from the cache. This ask is stopped first, so the manager is never asked twice at once.
|
|
109
|
+
stop.abort();
|
|
110
|
+
if (helpers.started < MAX_HELPERS) {
|
|
111
|
+
helpers.started += 1;
|
|
112
|
+
await deps.fill?.(ask).catch(() => undefined);
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
return { answer, asked: true };
|
|
117
|
+
};
|
|
118
|
+
const recordOf = (input, crossing, novelty, read, policy, decision) => ({
|
|
119
|
+
kind: 'crossing',
|
|
120
|
+
v: GATE_VERSION,
|
|
121
|
+
via: 'hook',
|
|
122
|
+
day: input.ts.slice(0, 10),
|
|
123
|
+
class: crossing.class,
|
|
124
|
+
form: crossing.form,
|
|
125
|
+
program: crossing.program,
|
|
126
|
+
versionForm: crossing.versionForm,
|
|
127
|
+
statusReports: crossing.statusReports,
|
|
128
|
+
// What a transcript gives is not read here and not guessed: replay fills these in.
|
|
129
|
+
origin: 'not-read',
|
|
130
|
+
novelty,
|
|
131
|
+
decidedBy: 'not-read',
|
|
132
|
+
permission: input.permission,
|
|
133
|
+
status: 'not-read',
|
|
134
|
+
next: 'not-read',
|
|
135
|
+
removedLater: 'not-read',
|
|
136
|
+
depth: input.agent === null ? 'main' : 'subagent',
|
|
137
|
+
miss: 'not-read',
|
|
138
|
+
againstLatest: read === undefined ? 'not-read' : againstLatestOf(crossing, read.answer),
|
|
139
|
+
registryState: read?.answer.state ?? 'not-read',
|
|
140
|
+
registryAge: read === undefined ? 'not-read' : ageOf(read.answer, input.ts),
|
|
141
|
+
arm: decision.arm,
|
|
142
|
+
lineShown: decision.arm === 'held' ? 'none' : decision.lineId,
|
|
143
|
+
lineHeld: decision.arm === 'held' ? decision.lineId : 'none',
|
|
144
|
+
policy,
|
|
145
|
+
session: input.session,
|
|
146
|
+
agent: input.agent,
|
|
147
|
+
toolUseId: input.toolUseId,
|
|
148
|
+
ts: input.ts,
|
|
149
|
+
name: crossing.name,
|
|
150
|
+
version: crossing.version,
|
|
151
|
+
});
|
|
152
|
+
const appendText = async (file, body) => {
|
|
153
|
+
await mkdir(join(file, '..'), { recursive: true });
|
|
154
|
+
await appendFile(file, body, { mode: 0o600 });
|
|
155
|
+
};
|
|
156
|
+
/** A row for a note that never reached the agent: no exposure, no arm. Used when a deny prints instead of the note. */
|
|
157
|
+
const withoutExposure = (row) => row.arm === 'none' ? row : { ...row, arm: 'none', lineShown: 'none', lineHeld: 'none' };
|
|
158
|
+
/** A block is a deny whose reason is the label; else the notes are context for the agent. Never an allow, an ask, or a changed input: with neither, nothing is printed. */
|
|
159
|
+
const outputOf = (blocks, notes) => {
|
|
160
|
+
if (blocks.length > 0)
|
|
161
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: blocks.join('\n') } });
|
|
162
|
+
return notes.length > 0 ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: notes.join('\n') } }) : '';
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* Records the crossings of one tool call. Resolves with the JSON to print: a note as context, or a deny labeled with the fact; never an allow or a changed call.
|
|
166
|
+
* Nothing is written, and no network is touched, for a call that crosses nothing.
|
|
167
|
+
*/
|
|
168
|
+
export const runGateHook = async (stdin, deps) => {
|
|
169
|
+
const input = parse(stdin);
|
|
170
|
+
const tool = text(input.tool_name);
|
|
171
|
+
const session = text(input.session_id);
|
|
172
|
+
if (tool === null || session === null)
|
|
173
|
+
return '';
|
|
174
|
+
const call = { tool, input: input.tool_input };
|
|
175
|
+
let check = checkCall(call, makeRedactor());
|
|
176
|
+
if (check.crossings.length === 0)
|
|
177
|
+
return '';
|
|
178
|
+
const domains = await redactDomains(deps.storeDir);
|
|
179
|
+
if (domains.length > 0)
|
|
180
|
+
check = checkCall(call, makeRedactor(domains));
|
|
181
|
+
const append = deps.append ?? appendText;
|
|
182
|
+
const cwd = text(input.cwd);
|
|
183
|
+
const ts = new Date(deps.now()).toISOString();
|
|
184
|
+
const common = { session, toolUseId: text(input.tool_use_id), agent: text(input.agent_id), permission: permissionOf(text(input.permission_mode) ?? 'unknown'), ts };
|
|
185
|
+
const index = await loadIndex(deps.storeDir, crossingsPath(deps.storeDir));
|
|
186
|
+
const repository = cwd === null ? '' : await repositoryOfCwd(cwd);
|
|
187
|
+
const kept = await keptAnswers(deps.storeDir);
|
|
188
|
+
const { policy, source } = await loadPolicy(deps.storeDir);
|
|
189
|
+
const random = deps.random ?? Math.random;
|
|
190
|
+
const blocks = [];
|
|
191
|
+
const notes = [];
|
|
192
|
+
const rows = [];
|
|
193
|
+
const lines = new Set();
|
|
194
|
+
const answered = [];
|
|
195
|
+
const binMemo = new Map();
|
|
196
|
+
const inCall = new Set();
|
|
197
|
+
const helpers = { started: 0 };
|
|
198
|
+
for (const seen of check.crossings) {
|
|
199
|
+
// A project's own bin is a stat walk, local and fast; it runs before the registry ask so a bin that is there is never asked about.
|
|
200
|
+
const crossing = await withLocalBin(seen, cwd, binMemo);
|
|
201
|
+
const scopes = scopesOf(keyOf(crossing), repository, session);
|
|
202
|
+
const { novelty, add, noted: indexed } = noveltyOf(index, scopes);
|
|
203
|
+
// One name twice in a call is one sight: the repeat reads as noted, so its note does not print again and its row is not a second draw.
|
|
204
|
+
const key = keyOf(crossing);
|
|
205
|
+
const noted = indexed || inCall.has(key);
|
|
206
|
+
inCall.add(key);
|
|
207
|
+
for (const line of add)
|
|
208
|
+
lines.add(line);
|
|
209
|
+
// What a process cut off during this ask writes: the rows so far and a not-read row for this crossing (the cut-off hook prints nothing, so none shows a note).
|
|
210
|
+
const hold = (stop) => {
|
|
211
|
+
const unread = recordOf(common, crossing, novelty, undefined, source, { arm: 'none', lineId: 'none', kind: 'none', text: null });
|
|
212
|
+
deps.stage?.({ file: crossingsPath(deps.storeDir), text: [...rows, unread].map((row) => `${JSON.stringify(row)}\n`).join(''), stop });
|
|
213
|
+
};
|
|
214
|
+
const read = await registryAnswer(deps, crossing, cwd, kept, helpers, hold);
|
|
215
|
+
deps.stage?.(null);
|
|
216
|
+
const state = read?.answer.state;
|
|
217
|
+
const answeredState = state === 'found' || state === 'not-found' ? state : undefined;
|
|
218
|
+
// A sight with no answer (a cut ask, no time, a name not asked, an unreachable registry) spends nothing: the session's note waits for the first sight that has one.
|
|
219
|
+
if (answeredState !== undefined && !noted)
|
|
220
|
+
lines.add(scopes.read);
|
|
221
|
+
if (read?.asked === true && read.answer.state !== 'unreachable') {
|
|
222
|
+
answered.push({ kind: 'registry', v: GATE_VERSION, day: ts.slice(0, 10), ts, folder: folderKey(cwd ?? ''), class: crossing.class, state: read.answer.state, created: read.answer.created, name: crossing.name, latest: read.answer.latest });
|
|
223
|
+
}
|
|
224
|
+
const decision = decide(policy, answeredState === undefined ? undefined : { state: answeredState, created: read?.answer.created ?? null, noted }, deps.now(), random, crossing.name);
|
|
225
|
+
if (decision.kind === 'block' && decision.text !== null && !blocks.includes(decision.text))
|
|
226
|
+
blocks.push(decision.text);
|
|
227
|
+
if (decision.kind === 'note' && decision.text !== null)
|
|
228
|
+
notes.push(decision.text);
|
|
229
|
+
rows.push(recordOf(common, crossing, novelty, read, source, decision));
|
|
230
|
+
}
|
|
231
|
+
// A call that holds a block prints only the deny, so a note row (shown or held) was never in front of the agent: it reads as no exposure, since a row cannot be backfilled.
|
|
232
|
+
const written = blocks.length > 0 ? rows.map(withoutExposure) : rows;
|
|
233
|
+
// The record first: an index line for a row that was never written would call the next crossing of the name old.
|
|
234
|
+
const record = { file: crossingsPath(deps.storeDir), text: written.map((row) => `${JSON.stringify(row)}\n`).join('') };
|
|
235
|
+
deps.stage?.(record);
|
|
236
|
+
try {
|
|
237
|
+
await append(record.file, record.text);
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
deps.stage?.(null);
|
|
241
|
+
}
|
|
242
|
+
await keepIndex(deps.storeDir, index, [...lines]).catch(() => undefined);
|
|
243
|
+
if (answered.length > 0)
|
|
244
|
+
await append(registryCachePath(deps.storeDir), answered.map((row) => `${JSON.stringify(row)}\n`).join('')).catch(() => undefined);
|
|
245
|
+
return outputOf(blocks, notes);
|
|
246
|
+
};
|
|
247
|
+
/** What the hook writes to stdout: the JSON of a note or a deny, or '' when silent and on any failure, so the call runs as asked. */
|
|
248
|
+
export const gateHook = async (stdin, deps) => {
|
|
249
|
+
try {
|
|
250
|
+
return await runGateHook(stdin, deps);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Fail-open: a recorder that cannot record must not cost a session its tool call.
|
|
254
|
+
}
|
|
255
|
+
return '';
|
|
256
|
+
};
|