heedvane 0.0.0-stage → 0.2.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 +62 -2
- package/dist/apply.d.ts +104 -0
- package/dist/apply.js +388 -0
- package/dist/backend/client.d.ts +40 -0
- package/dist/backend/client.js +32 -0
- package/dist/backend/credentials.d.ts +15 -0
- package/dist/backend/credentials.js +76 -0
- package/dist/backend/login.d.ts +33 -0
- package/dist/backend/login.js +265 -0
- package/dist/backend/repo-remote.d.ts +15 -0
- package/dist/backend/repo-remote.js +61 -0
- package/dist/backend/sign-in-change.d.ts +12 -0
- package/dist/backend/sign-in-change.js +72 -0
- package/dist/best-effort.d.ts +4 -0
- package/dist/best-effort.js +25 -0
- package/dist/cli.d.ts +29 -0
- package/dist/cli.js +666 -0
- package/dist/clients/definitions.d.ts +113 -0
- package/dist/clients/definitions.js +427 -0
- package/dist/clients/hermes-plugin.d.ts +33 -0
- package/dist/clients/hermes-plugin.js +247 -0
- package/dist/commands/heedvane.d.ts +15 -0
- package/dist/commands/heedvane.js +74 -0
- package/dist/commands/memory.d.ts +10 -0
- package/dist/commands/memory.js +106 -0
- package/dist/config-doc.d.ts +97 -0
- package/dist/config-doc.js +465 -0
- package/dist/dispatchers/posix.d.ts +41 -0
- package/dist/dispatchers/posix.js +208 -0
- package/dist/dispatchers/snippets.d.ts +6 -0
- package/dist/dispatchers/snippets.js +39 -0
- package/dist/dispatchers/windows.d.ts +15 -0
- package/dist/dispatchers/windows.js +50 -0
- package/dist/doctor.d.ts +37 -0
- package/dist/doctor.js +303 -0
- package/dist/events/contract.d.ts +54 -0
- package/dist/events/contract.js +87 -0
- package/dist/events/normalize.d.ts +36 -0
- package/dist/events/normalize.js +143 -0
- package/dist/guards.d.ts +18 -0
- package/dist/guards.js +28 -0
- package/dist/heed-file.d.ts +78 -0
- package/dist/heed-file.js +316 -0
- package/dist/installer.d.ts +47 -0
- package/dist/installer.js +174 -0
- package/dist/listener/background.d.ts +8 -0
- package/dist/listener/background.js +36 -0
- package/dist/listener/endpoint.d.ts +26 -0
- package/dist/listener/endpoint.js +125 -0
- package/dist/listener/forward.d.ts +36 -0
- package/dist/listener/forward.js +257 -0
- package/dist/listener/serve.d.ts +2 -0
- package/dist/listener/serve.js +155 -0
- package/dist/listener/server.d.ts +48 -0
- package/dist/listener/server.js +251 -0
- package/dist/listener/spool.d.ts +28 -0
- package/dist/listener/spool.js +60 -0
- package/dist/mcp-server.d.ts +9 -0
- package/dist/mcp-server.js +187 -0
- package/dist/mcp.d.ts +57 -0
- package/dist/mcp.js +83 -0
- package/dist/memory/private-memory.d.ts +12 -0
- package/dist/memory/private-memory.js +54 -0
- package/dist/memory/table-of-contents.d.ts +10 -0
- package/dist/memory/table-of-contents.js +80 -0
- package/dist/memory/team-memory.d.ts +60 -0
- package/dist/memory/team-memory.js +54 -0
- package/dist/plan.d.ts +77 -0
- package/dist/plan.js +288 -0
- package/dist/render.d.ts +32 -0
- package/dist/render.js +132 -0
- package/dist/reply/harness-output.d.ts +57 -0
- package/dist/reply/harness-output.js +88 -0
- package/dist/reply/reply.d.ts +65 -0
- package/dist/reply/reply.js +169 -0
- package/dist/reply/verdict.d.ts +28 -0
- package/dist/reply/verdict.js +17 -0
- package/dist/skill/bundle.d.ts +23 -0
- package/dist/skill/bundle.js +102 -0
- package/dist/skill/content.d.ts +16 -0
- package/dist/skill/content.js +182 -0
- package/dist/skills/approved.d.ts +31 -0
- package/dist/skills/approved.js +123 -0
- package/dist/skills/check.d.ts +21 -0
- package/dist/skills/check.js +83 -0
- package/dist/state.d.ts +51 -0
- package/dist/state.js +118 -0
- package/dist/steering/rules.d.ts +49 -0
- package/dist/steering/rules.js +165 -0
- package/dist/steering/steer.d.ts +29 -0
- package/dist/steering/steer.js +64 -0
- package/dist/steering/violations.d.ts +27 -0
- package/dist/steering/violations.js +24 -0
- package/dist/uninstall.d.ts +23 -0
- package/dist/uninstall.js +159 -0
- package/package.json +35 -4
- package/skills/heedvane-hooks/SKILL.md +87 -0
- package/skills/heedvane-hooks/references/events.md +69 -0
package/dist/state.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import writeFileAtomic from "write-file-atomic";
|
|
4
|
+
import { errorMessage, isRecord } from "./guards.js";
|
|
5
|
+
/** Error for a state.json that exists but cannot be trusted. Carries the exact remediation. */
|
|
6
|
+
export class HeedStateCorruptError extends Error {
|
|
7
|
+
path;
|
|
8
|
+
constructor(path, reason) {
|
|
9
|
+
super(`corrupt heedvane state file at ${path}: ${reason}. Fix: delete ${path} or run npx heedvane doctor`);
|
|
10
|
+
this.name = "HeedStateCorruptError";
|
|
11
|
+
this.path = path;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function statePath(root) {
|
|
15
|
+
return join(root, "state.json");
|
|
16
|
+
}
|
|
17
|
+
/** Protocol version stamped into new state files. */
|
|
18
|
+
const PROTOCOL_VERSION = "1";
|
|
19
|
+
/**
|
|
20
|
+
* A fresh, empty state for a new install. Endpoint starts unassigned
|
|
21
|
+
* (port 0 / empty token) until the listener resolves and persists one.
|
|
22
|
+
*/
|
|
23
|
+
export function emptyState(skillDigest = null) {
|
|
24
|
+
return {
|
|
25
|
+
schemaVersion: 1,
|
|
26
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
27
|
+
endpoint: { port: 0, token: "" },
|
|
28
|
+
installed: {},
|
|
29
|
+
skill: skillDigest === null ? null : { version: "1", digest: skillDigest },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** The `installed` row shape: string entries plus a string→string digest map. */
|
|
33
|
+
function isInstalledRow(installed) {
|
|
34
|
+
if (!isRecord(installed))
|
|
35
|
+
return false;
|
|
36
|
+
if (!Array.isArray(installed["entries"]) || !installed["entries"].every((e) => typeof e === "string")) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const digests = installed["digests"];
|
|
40
|
+
if (!isRecord(digests))
|
|
41
|
+
return false;
|
|
42
|
+
for (const digest of Object.values(digests)) {
|
|
43
|
+
if (typeof digest !== "string")
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
/** The optional `skill` block: absent, or a {version, digest} record of strings. */
|
|
49
|
+
function isSkillBlock(skill) {
|
|
50
|
+
if (skill === null || skill === undefined)
|
|
51
|
+
return true;
|
|
52
|
+
if (!isRecord(skill))
|
|
53
|
+
return false;
|
|
54
|
+
return typeof skill["version"] === "string" && typeof skill["digest"] === "string";
|
|
55
|
+
}
|
|
56
|
+
/** Structural validation: required fields present and well-typed; unknown extras ignored. */
|
|
57
|
+
function isHeedState(value) {
|
|
58
|
+
if (!isRecord(value))
|
|
59
|
+
return false;
|
|
60
|
+
if (value["schemaVersion"] !== 1)
|
|
61
|
+
return false;
|
|
62
|
+
if (typeof value["protocolVersion"] !== "string" || value["protocolVersion"].length === 0) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const endpoint = value["endpoint"];
|
|
66
|
+
if (!isRecord(endpoint))
|
|
67
|
+
return false;
|
|
68
|
+
if (!Number.isInteger(endpoint["port"]) || typeof endpoint["token"] !== "string")
|
|
69
|
+
return false;
|
|
70
|
+
const installed = value["installed"];
|
|
71
|
+
if (!isRecord(installed))
|
|
72
|
+
return false;
|
|
73
|
+
for (const row of Object.values(installed)) {
|
|
74
|
+
if (!isInstalledRow(row))
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return isSkillBlock(value["skill"]);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Read <root>/state.json. Returns null when the file does not exist.
|
|
81
|
+
* Throws {@link HeedStateCorruptError} (remediation: "delete <path> or run
|
|
82
|
+
* npx heedvane doctor") when the file exists but is unparseable or does not
|
|
83
|
+
* match the HeedState schema.
|
|
84
|
+
*/
|
|
85
|
+
export async function readState(root) {
|
|
86
|
+
const path = statePath(root);
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
text = await readFile(path, "utf8");
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (isRecord(err) && err["code"] === "ENOENT")
|
|
93
|
+
return null;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
let parsed;
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(text);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
const reason = errorMessage(err);
|
|
102
|
+
throw new HeedStateCorruptError(path, `not valid JSON (${reason})`);
|
|
103
|
+
}
|
|
104
|
+
if (!isHeedState(parsed)) {
|
|
105
|
+
throw new HeedStateCorruptError(path, "does not match the HeedState schema (schemaVersion 1)");
|
|
106
|
+
}
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Atomically write state to <root>/state.json (create parent dirs, mode 0600).
|
|
111
|
+
* write-file-atomic writes to a sibling temp file and renames, so readers never
|
|
112
|
+
* observe a partial state.
|
|
113
|
+
*/
|
|
114
|
+
export async function writeStateAtomic(root, state) {
|
|
115
|
+
await mkdir(root, { recursive: true });
|
|
116
|
+
const payload = `${JSON.stringify(state, null, 2)}\n`;
|
|
117
|
+
await writeFileAtomic(statePath(root), payload, { mode: 0o600 });
|
|
118
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Backend } from "../backend/client.js";
|
|
2
|
+
import type { Envelope } from "../events/contract.js";
|
|
3
|
+
import { type Verdict } from "../reply/verdict.js";
|
|
4
|
+
export interface DeterministicRule {
|
|
5
|
+
id: string;
|
|
6
|
+
kind: "command_contains" | "path_glob";
|
|
7
|
+
tools: string[] | null;
|
|
8
|
+
/** `fragments` for command_contains, `globs` for path_glob. */
|
|
9
|
+
patterns: string[];
|
|
10
|
+
message: string;
|
|
11
|
+
/** The rule's version, named in delivery reports; absent from a server that does not send it. */
|
|
12
|
+
sha256?: string;
|
|
13
|
+
}
|
|
14
|
+
/** An organization instruction, injected as context; it never blocks. */
|
|
15
|
+
export interface Instruction {
|
|
16
|
+
id: string;
|
|
17
|
+
text: string;
|
|
18
|
+
inject: "session_start" | "every_prompt";
|
|
19
|
+
}
|
|
20
|
+
export interface SteeringPolicy {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
rules: DeterministicRule[];
|
|
23
|
+
instructions: Instruction[];
|
|
24
|
+
}
|
|
25
|
+
/** Heedvane's COMMAND_TOO_LONG_MESSAGE: what an over-budget command is enforced with, instead of each rule's advice. */
|
|
26
|
+
export declare const COMMAND_TOO_LONG_MESSAGE = "This command is too long for Heedvane to check against your organization's rules; split it into shorter commands.";
|
|
27
|
+
/** The policy in a rules answer; null when it is not one. Rules and instructions that do not parse are skipped. */
|
|
28
|
+
export declare function steeringPolicy(value: unknown): SteeringPolicy | null;
|
|
29
|
+
/**
|
|
30
|
+
* The matched rules' verdict for one `pre_tool_use`: every matched rule enforces, in policy order. A
|
|
31
|
+
* command too long to read within Heedvane's work budget is enforced unread by every command rule
|
|
32
|
+
* (`unchecked`), with one message saying so.
|
|
33
|
+
*/
|
|
34
|
+
export declare function deterministicVerdict(envelope: Envelope, rules: readonly DeterministicRule[]): Verdict;
|
|
35
|
+
/** The instructions' text for an event: `session_start` ones at session start, `every_prompt` ones with each prompt, in policy order. */
|
|
36
|
+
export declare function instructionContext(policy: SteeringPolicy | null, event: string): string[];
|
|
37
|
+
/** The sync's outcome: the policy with the answer it came in (cached as is), or the status Heedvane refused it with (null: no answer). */
|
|
38
|
+
export type PolicySync = {
|
|
39
|
+
policy: SteeringPolicy;
|
|
40
|
+
answer: unknown;
|
|
41
|
+
} | {
|
|
42
|
+
status: number | null;
|
|
43
|
+
};
|
|
44
|
+
export declare function fetchSteeringPolicy(backend: Backend, timeoutMs: number): Promise<PolicySync>;
|
|
45
|
+
export declare function steeringPolicyPath(root: string): string;
|
|
46
|
+
/** The last synced policy, or null before the first sync or when the cache does not parse. */
|
|
47
|
+
export declare function readSteeringPolicy(root: string, identity: string | null): Promise<SteeringPolicy | null>;
|
|
48
|
+
/** Keep a rules answer as Heedvane sent it, plus whose sign-in fetched it (`cachedFor`). */
|
|
49
|
+
export declare function saveSteeringPolicy(root: string, answer: unknown, identity: string | null): Promise<void>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The organization's steering policy on this machine: `GET /api/agent/steering/rules` answers
|
|
3
|
+
* `{ enabled, rules, instructions }` (docs/product/agent-integration/steering.md, "Rule format" and
|
|
4
|
+
* "Organization instructions"). The deterministic rules (`command_contains`, `path_glob`) are evaluated
|
|
5
|
+
* here on `pre_tool_use`, the only event that carries the tool input they read, so a slow or failed
|
|
6
|
+
* server answer can never cancel them. They match exactly as Heedvane's own evaluation does
|
|
7
|
+
* (`deterministicResult` and `touchedFile` in apps/web): a glob sees the first of `file_path`,
|
|
8
|
+
* `notebook_path`, `path`, relative to `cwd` when inside it, normalized, at most 1,024 characters; a
|
|
9
|
+
* command too long to read within the work budget enforces every command rule unread (`unchecked`).
|
|
10
|
+
* Semantic rules are evaluated by Heedvane, which answers the event with its verdict; they are ignored here.
|
|
11
|
+
*
|
|
12
|
+
* The last synced policy is kept in `<root>/steering-policy.json`, so a restarted listener enforces and
|
|
13
|
+
* injects instructions before its first sync, and with Heedvane down.
|
|
14
|
+
*/
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { isAbsolute, join, matchesGlob, normalize, relative } from "node:path";
|
|
17
|
+
import writeFileAtomic from "write-file-atomic";
|
|
18
|
+
import { callHeedvane } from "../backend/client.js";
|
|
19
|
+
import { isRecord } from "../guards.js";
|
|
20
|
+
import { ALLOW } from "../reply/verdict.js";
|
|
21
|
+
/** Heedvane's MAX_MATCHED_PATH: a longer (normalized) path matches no glob. */
|
|
22
|
+
const MAX_MATCHED_PATH = 1024;
|
|
23
|
+
/** Heedvane's MAX_COMMAND_MATCH_WORK: command length × the applicable fragments' characters it reads at most. */
|
|
24
|
+
const MAX_COMMAND_MATCH_WORK = 130_000_000;
|
|
25
|
+
/** Heedvane's COMMAND_TOO_LONG_MESSAGE: what an over-budget command is enforced with, instead of each rule's advice. */
|
|
26
|
+
export const COMMAND_TOO_LONG_MESSAGE = "This command is too long for Heedvane to check against your organization's rules; split it into shorter commands.";
|
|
27
|
+
/** The policy in a rules answer; null when it is not one. Rules and instructions that do not parse are skipped. */
|
|
28
|
+
export function steeringPolicy(value) {
|
|
29
|
+
if (!isRecord(value) || typeof value["enabled"] !== "boolean" || !Array.isArray(value["rules"]))
|
|
30
|
+
return null;
|
|
31
|
+
const instructions = Array.isArray(value["instructions"]) ? value["instructions"] : [];
|
|
32
|
+
return {
|
|
33
|
+
enabled: value["enabled"],
|
|
34
|
+
rules: value["rules"].flatMap((rule) => parsed(parseRule(rule))),
|
|
35
|
+
instructions: instructions.flatMap((instruction) => parsed(parseInstruction(instruction))),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function parsed(value) {
|
|
39
|
+
return value === null ? [] : [value];
|
|
40
|
+
}
|
|
41
|
+
/** A rule this machine can enforce: deterministic, and on `pre_tool_use` (the default). */
|
|
42
|
+
function parseRule(rule) {
|
|
43
|
+
if (!isRecord(rule) || typeof rule["id"] !== "string" || typeof rule["message"] !== "string")
|
|
44
|
+
return null;
|
|
45
|
+
const kind = rule["kind"];
|
|
46
|
+
if (kind !== "command_contains" && kind !== "path_glob")
|
|
47
|
+
return null;
|
|
48
|
+
if (isStrings(rule["events"]) && !rule["events"].includes("pre_tool_use"))
|
|
49
|
+
return null;
|
|
50
|
+
const patterns = kind === "command_contains" ? rule["fragments"] : rule["globs"];
|
|
51
|
+
if (!isStrings(patterns))
|
|
52
|
+
return null;
|
|
53
|
+
return {
|
|
54
|
+
id: rule["id"],
|
|
55
|
+
kind,
|
|
56
|
+
tools: isStrings(rule["tools"]) ? rule["tools"] : null,
|
|
57
|
+
patterns,
|
|
58
|
+
message: rule["message"],
|
|
59
|
+
...(typeof rule["sha256"] === "string" ? { sha256: rule["sha256"] } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function parseInstruction(instruction) {
|
|
63
|
+
if (!isRecord(instruction) || typeof instruction["id"] !== "string" || typeof instruction["text"] !== "string")
|
|
64
|
+
return null;
|
|
65
|
+
const inject = instruction["inject"];
|
|
66
|
+
return inject === "session_start" || inject === "every_prompt" ? { id: instruction["id"], text: instruction["text"], inject } : null;
|
|
67
|
+
}
|
|
68
|
+
function isStrings(value) {
|
|
69
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The matched rules' verdict for one `pre_tool_use`: every matched rule enforces, in policy order. A
|
|
73
|
+
* command too long to read within Heedvane's work budget is enforced unread by every command rule
|
|
74
|
+
* (`unchecked`), with one message saying so.
|
|
75
|
+
*/
|
|
76
|
+
export function deterministicVerdict(envelope, rules) {
|
|
77
|
+
if (envelope.event !== "pre_tool_use")
|
|
78
|
+
return ALLOW;
|
|
79
|
+
const input = envelope.payload["tool_input"];
|
|
80
|
+
const applicable = rules.filter((rule) => applies(rule, envelope));
|
|
81
|
+
const unchecked = commandTooLong(input, applicable);
|
|
82
|
+
const matched = applicable.filter((rule) => (unchecked && rule.kind === "command_contains") || matches(rule, input, envelope.payload["cwd"]));
|
|
83
|
+
if (matched.length === 0)
|
|
84
|
+
return ALLOW;
|
|
85
|
+
const message = (rule) => (unchecked && rule.kind === "command_contains" ? COMMAND_TOO_LONG_MESSAGE : rule.message);
|
|
86
|
+
return {
|
|
87
|
+
decision: "steer",
|
|
88
|
+
messages: [...new Set(matched.map(message))],
|
|
89
|
+
context: [],
|
|
90
|
+
enforcements: matched.map((rule) => ({ ruleId: rule.id, ...(rule.sha256 === undefined ? {} : { ruleSha256: rule.sha256 }) })),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function applies(rule, envelope) {
|
|
94
|
+
const tool = envelope.payload["tool"];
|
|
95
|
+
return rule.tools === null || (typeof tool === "string" && rule.tools.includes(tool));
|
|
96
|
+
}
|
|
97
|
+
function commandTooLong(input, rules) {
|
|
98
|
+
const command = isRecord(input) ? input["command"] : undefined;
|
|
99
|
+
const fragmentCharacters = rules.reduce((total, rule) => total + (rule.kind === "command_contains" ? rule.patterns.join("").length : 0), 0);
|
|
100
|
+
return typeof command === "string" && command.length * fragmentCharacters > MAX_COMMAND_MATCH_WORK;
|
|
101
|
+
}
|
|
102
|
+
function matches(rule, input, cwd) {
|
|
103
|
+
if (!isRecord(input))
|
|
104
|
+
return false;
|
|
105
|
+
if (rule.kind === "command_contains") {
|
|
106
|
+
const command = input["command"];
|
|
107
|
+
return typeof command === "string" && rule.patterns.some((fragment) => command.includes(fragment));
|
|
108
|
+
}
|
|
109
|
+
const touched = touchedFile(input, cwd);
|
|
110
|
+
// Normalized first, so `./././` padding cannot push a real file past the length bound.
|
|
111
|
+
const path = touched === null ? null : normalize(touched);
|
|
112
|
+
return path !== null && path.length <= MAX_MATCHED_PATH && rule.patterns.some((glob) => matchesGlob(path, glob));
|
|
113
|
+
}
|
|
114
|
+
/** The file a tool event touches, relative to `cwd` when inside it (Heedvane's `touchedFile`). */
|
|
115
|
+
function touchedFile(input, cwd) {
|
|
116
|
+
const path = ["file_path", "notebook_path", "path"].map((key) => input[key]).find((value) => typeof value === "string" && value !== "");
|
|
117
|
+
if (path === undefined)
|
|
118
|
+
return null;
|
|
119
|
+
if (typeof cwd !== "string" || cwd === "" || !isAbsolute(path))
|
|
120
|
+
return path;
|
|
121
|
+
const inside = relative(cwd, path);
|
|
122
|
+
return inside !== "" && !inside.startsWith("..") ? inside : path;
|
|
123
|
+
}
|
|
124
|
+
/** The instructions' text for an event: `session_start` ones at session start, `every_prompt` ones with each prompt, in policy order. */
|
|
125
|
+
export function instructionContext(policy, event) {
|
|
126
|
+
const inject = { session_start: "session_start", user_prompt_submit: "every_prompt" }[event];
|
|
127
|
+
if (policy === null || !policy.enabled || inject === undefined)
|
|
128
|
+
return [];
|
|
129
|
+
return policy.instructions.filter((instruction) => instruction.inject === inject).map((instruction) => instruction.text);
|
|
130
|
+
}
|
|
131
|
+
export async function fetchSteeringPolicy(backend, timeoutMs) {
|
|
132
|
+
const answer = await callHeedvane(backend, { method: "GET", path: "/api/agent/steering/rules", timeoutMs });
|
|
133
|
+
const policy = answer?.status === 200 ? steeringPolicy(answer.json) : null;
|
|
134
|
+
return policy === null ? { status: answer?.status ?? null } : { policy, answer: answer.json };
|
|
135
|
+
}
|
|
136
|
+
export function steeringPolicyPath(root) {
|
|
137
|
+
return join(root, "steering-policy.json");
|
|
138
|
+
}
|
|
139
|
+
/** The last synced policy, or null before the first sync or when the cache does not parse. */
|
|
140
|
+
export async function readSteeringPolicy(root, identity) {
|
|
141
|
+
let text;
|
|
142
|
+
try {
|
|
143
|
+
text = await readFile(steeringPolicyPath(root), "utf8");
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (err.code === "ENOENT")
|
|
147
|
+
return null;
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const cached = JSON.parse(text);
|
|
152
|
+
// Only the rules this very sign-in fetched apply: another organization's, one from before identities were kept, and any with no sign-in do not.
|
|
153
|
+
if (!isRecord(cached) || identity === null || cached["cachedFor"] !== identity)
|
|
154
|
+
return null;
|
|
155
|
+
return steeringPolicy(cached);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null; // a torn or hand-edited cache: wait for the next sync rather than enforce a guess
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Keep a rules answer as Heedvane sent it, plus whose sign-in fetched it (`cachedFor`). */
|
|
162
|
+
export async function saveSteeringPolicy(root, answer, identity) {
|
|
163
|
+
const cached = identity !== null && isRecord(answer) ? { ...answer, cachedFor: identity } : answer;
|
|
164
|
+
await writeFileAtomic(steeringPolicyPath(root), `${JSON.stringify(cached, null, 2)}\n`, { mode: 0o600 });
|
|
165
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The organization's steering for one event (docs/product/agent-integration/steering.md, "Hook response
|
|
3
|
+
* contract"). A hook never reads the network. The synced deterministic rules decide `pre_tool_use` locally
|
|
4
|
+
* (`localVerdict`) and enforce at once. While the policy is enabled every steered event also goes to
|
|
5
|
+
* `POST /api/agent/steering`, also the ones nothing enforces, so Heedvane can tell whether the agent
|
|
6
|
+
* complied; that call runs in the background (`steer`) and its `enforce` or `halt` answer becomes an open
|
|
7
|
+
* violation (src/steering/violations.ts), delivered at the agent's next tool call or stop. Steering never
|
|
8
|
+
* only warns: every matched rule enforces.
|
|
9
|
+
*/
|
|
10
|
+
import { type Backend } from "../backend/client.js";
|
|
11
|
+
import type { Envelope } from "../events/contract.js";
|
|
12
|
+
import { type Verdict } from "../reply/verdict.js";
|
|
13
|
+
import { type DeterministicRule } from "./rules.js";
|
|
14
|
+
/** The events steering enforces on (a rule on any other event is refused by Heedvane). */
|
|
15
|
+
export declare const STEERED_EVENTS: ReadonlySet<string>;
|
|
16
|
+
/** Who decided an enforcement: this machine's synced rules, or Heedvane's background verdict, delivered with a later event. */
|
|
17
|
+
export type Delivery = "local" | "next_event";
|
|
18
|
+
export interface SteeringRequest {
|
|
19
|
+
envelope: Envelope;
|
|
20
|
+
repository: string;
|
|
21
|
+
/** The event's tool input before the payload caps: the local rules match on it, and Heedvane's evaluation (held in memory there, never stored) is sent it. The forwarded, recorded envelope stays capped. */
|
|
22
|
+
toolInput?: unknown;
|
|
23
|
+
}
|
|
24
|
+
/** The synced deterministic rules' verdict for one event; they see the whole tool input, not the capped one. */
|
|
25
|
+
export declare function localVerdict({ envelope, toolInput }: SteeringRequest, rules: readonly DeterministicRule[]): Verdict;
|
|
26
|
+
/** Heedvane's answer for the event: `enforce` and `halt` enforce; null when it did not answer in time or validly. */
|
|
27
|
+
export declare function steer(backend: Backend, request: SteeringRequest): Promise<Verdict | null>;
|
|
28
|
+
/** What `POST /api/agent/steering` reads at most (steering.md "Hook response contract": 413 past it). */
|
|
29
|
+
export declare const MAX_STEERING_REQUEST_BYTES: number;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The organization's steering for one event (docs/product/agent-integration/steering.md, "Hook response
|
|
3
|
+
* contract"). A hook never reads the network. The synced deterministic rules decide `pre_tool_use` locally
|
|
4
|
+
* (`localVerdict`) and enforce at once. While the policy is enabled every steered event also goes to
|
|
5
|
+
* `POST /api/agent/steering`, also the ones nothing enforces, so Heedvane can tell whether the agent
|
|
6
|
+
* complied; that call runs in the background (`steer`) and its `enforce` or `halt` answer becomes an open
|
|
7
|
+
* violation (src/steering/violations.ts), delivered at the agent's next tool call or stop. Steering never
|
|
8
|
+
* only warns: every matched rule enforces.
|
|
9
|
+
*/
|
|
10
|
+
import { callHeedvane } from "../backend/client.js";
|
|
11
|
+
import { isRecord } from "../guards.js";
|
|
12
|
+
import { ALLOW } from "../reply/verdict.js";
|
|
13
|
+
import { deterministicVerdict } from "./rules.js";
|
|
14
|
+
/** The events steering enforces on (a rule on any other event is refused by Heedvane). */
|
|
15
|
+
export const STEERED_EVENTS = new Set(["pre_tool_use", "user_prompt_submit", "stop", "subagent_stop"]);
|
|
16
|
+
/** How long a background evaluation may take; no hook waits for it. */
|
|
17
|
+
const STEERING_TIMEOUT_MS = 15_000;
|
|
18
|
+
/** The synced deterministic rules' verdict for one event; they see the whole tool input, not the capped one. */
|
|
19
|
+
export function localVerdict({ envelope, toolInput }, rules) {
|
|
20
|
+
return deterministicVerdict(toolInput === undefined ? envelope : { ...envelope, payload: { ...envelope.payload, tool_input: toolInput } }, rules);
|
|
21
|
+
}
|
|
22
|
+
/** Heedvane's answer for the event: `enforce` and `halt` enforce; null when it did not answer in time or validly. */
|
|
23
|
+
export async function steer(backend, request) {
|
|
24
|
+
const body = steeringBody(request);
|
|
25
|
+
const answer = await callHeedvane(backend, { method: "POST", path: "/api/agent/steering", body, timeoutMs: STEERING_TIMEOUT_MS });
|
|
26
|
+
if (answer?.status !== 200 || !isRecord(answer.json))
|
|
27
|
+
return null;
|
|
28
|
+
const { decision, messages, enforcements } = answer.json;
|
|
29
|
+
if (decision === "allow")
|
|
30
|
+
return ALLOW;
|
|
31
|
+
if ((decision !== "enforce" && decision !== "halt") || !Array.isArray(messages))
|
|
32
|
+
return null;
|
|
33
|
+
return {
|
|
34
|
+
decision: "steer",
|
|
35
|
+
messages: messages.filter((message) => typeof message === "string"),
|
|
36
|
+
context: [],
|
|
37
|
+
enforcements: enforced(enforcements),
|
|
38
|
+
...(decision === "halt" ? { halt: true } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** What `POST /api/agent/steering` reads at most (steering.md "Hook response contract": 413 past it). */
|
|
42
|
+
export const MAX_STEERING_REQUEST_BYTES = 1024 * 1024;
|
|
43
|
+
/**
|
|
44
|
+
* The evaluation request. Its envelope carries the whole tool input, so an AI rule can see a harmful
|
|
45
|
+
* tail the 8 KB payload cap would cut; when that does not fit the request limit, the capped envelope
|
|
46
|
+
* goes instead and Heedvane treats a "no" about capped content as uncertain.
|
|
47
|
+
*/
|
|
48
|
+
function steeringBody(request) {
|
|
49
|
+
const { envelope, toolInput, repository } = request;
|
|
50
|
+
const withRepository = (value) => (repository === "" ? { envelope: value } : { envelope: value, repository });
|
|
51
|
+
if (toolInput === undefined)
|
|
52
|
+
return withRepository(envelope);
|
|
53
|
+
const whole = withRepository({ ...envelope, payload: { ...envelope.payload, tool_input: toolInput } });
|
|
54
|
+
return Buffer.byteLength(JSON.stringify(whole)) <= MAX_STEERING_REQUEST_BYTES ? whole : withRepository(envelope);
|
|
55
|
+
}
|
|
56
|
+
function enforced(value) {
|
|
57
|
+
if (!Array.isArray(value))
|
|
58
|
+
return [];
|
|
59
|
+
return value.flatMap((item) => {
|
|
60
|
+
if (!isRecord(item) || typeof item["ruleId"] !== "string")
|
|
61
|
+
return [];
|
|
62
|
+
return [{ ruleId: item["ruleId"], ...(typeof item["ruleSha256"] === "string" ? { ruleSha256: item["ruleSha256"] } : {}) }];
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heedvane's background verdicts that enforce, held per session until the agent's next tool call or
|
|
3
|
+
* stop carries them (docs/product/agent-integration/steering.md, "Hook response contract"). A verdict
|
|
4
|
+
* is delivered once: taking it removes it. The hook never waits for a verdict, so one still in flight
|
|
5
|
+
* when a stop is answered is delivered with a later event, or never if the session ends first.
|
|
6
|
+
*/
|
|
7
|
+
import type { Envelope } from "../events/contract.js";
|
|
8
|
+
import type { Verdict } from "../reply/verdict.js";
|
|
9
|
+
/** An enforcing verdict and the event Heedvane judged, which a delivery report names. */
|
|
10
|
+
export interface Violation {
|
|
11
|
+
judged: Envelope;
|
|
12
|
+
verdict: Verdict;
|
|
13
|
+
}
|
|
14
|
+
/** Open violations by session. shortcut: a session that never ends keeps its entry until the listener restarts; upgrade if sessions pile up. */
|
|
15
|
+
export type Violations = Map<string, Violation[]>;
|
|
16
|
+
/** The events whose answer carries an open violation. */
|
|
17
|
+
export declare const DELIVERY_EVENTS: ReadonlySet<string>;
|
|
18
|
+
export declare function openViolation(open: Violations, violation: Violation): void;
|
|
19
|
+
/** The session's open violations, removed: they are delivered exactly once. */
|
|
20
|
+
export declare function takeViolations(open: Violations, session: string): Violation[];
|
|
21
|
+
/**
|
|
22
|
+
* The verdict less the rules this machine's synced rules enforced on this very event (they denied it on
|
|
23
|
+
* the spot), or null when nothing else is left: the agent was told about those once. A rule the local copy
|
|
24
|
+
* did not match here stays, so a server that matches it still reaches the agent. Messages stay whole: they
|
|
25
|
+
* are not tied to a rule, and a duplicate reason is better than a lost one.
|
|
26
|
+
*/
|
|
27
|
+
export declare function withoutRules(verdict: Verdict, enforcedHere: ReadonlySet<string>): Verdict | null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The events whose answer carries an open violation. */
|
|
2
|
+
export const DELIVERY_EVENTS = new Set(["pre_tool_use", "stop", "subagent_stop"]);
|
|
3
|
+
export function openViolation(open, violation) {
|
|
4
|
+
const { session } = violation.judged;
|
|
5
|
+
open.set(session, [...(open.get(session) ?? []), violation]);
|
|
6
|
+
}
|
|
7
|
+
/** The session's open violations, removed: they are delivered exactly once. */
|
|
8
|
+
export function takeViolations(open, session) {
|
|
9
|
+
const taken = open.get(session) ?? [];
|
|
10
|
+
open.delete(session);
|
|
11
|
+
return taken;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The verdict less the rules this machine's synced rules enforced on this very event (they denied it on
|
|
15
|
+
* the spot), or null when nothing else is left: the agent was told about those once. A rule the local copy
|
|
16
|
+
* did not match here stays, so a server that matches it still reaches the agent. Messages stay whole: they
|
|
17
|
+
* are not tied to a rule, and a duplicate reason is better than a lost one.
|
|
18
|
+
*/
|
|
19
|
+
export function withoutRules(verdict, enforcedHere) {
|
|
20
|
+
if (enforcedHere.size === 0)
|
|
21
|
+
return verdict;
|
|
22
|
+
const enforcements = (verdict.enforcements ?? []).filter((enforced) => !enforcedHere.has(enforced.ruleId));
|
|
23
|
+
return enforcements.length === 0 ? null : { ...verdict, enforcements };
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ResolvedClient, type Env } from "./clients/definitions.js";
|
|
2
|
+
import { type ConfigFormat } from "./config-doc.js";
|
|
3
|
+
export interface UninstallItem {
|
|
4
|
+
clientId: string;
|
|
5
|
+
name: string;
|
|
6
|
+
path: string;
|
|
7
|
+
status: "removed" | "absent" | "skipped" | "failed";
|
|
8
|
+
detail?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface UninstallInput {
|
|
11
|
+
home: string;
|
|
12
|
+
env: Env;
|
|
13
|
+
agents?: string[];
|
|
14
|
+
purgeConfig?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Setup's half: our hook groups under any event this client no longer installs, removed from its hooks file. */
|
|
17
|
+
export declare function removeStaleHookGroups(client: ResolvedClient, target: {
|
|
18
|
+
path: string;
|
|
19
|
+
format: ConfigFormat;
|
|
20
|
+
}): Promise<UninstallItem[]>;
|
|
21
|
+
/** When hooksDeferredTo skips a client whose hooks an earlier run installed, remove our hook groups so events do not fire twice. */
|
|
22
|
+
export declare function removeDeferredHookGroups(client: ResolvedClient): Promise<UninstallItem[]>;
|
|
23
|
+
export declare function uninstall(input: UninstallInput): Promise<UninstallItem[]>;
|