@indigoai-us/hq-cli 5.322.0 → 5.324.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 +11 -0
- package/assets/scaffold/core/scripts/derive-trigger-facts.sh +263 -0
- package/assets/scaffold/core/scripts/lib/transcript-tail.sh +98 -0
- package/assets/scaffold/core/scripts/lib/trigger-fact-text.awk +86 -0
- package/dist/bin/hq-auth-refresh.d.ts +5 -1
- package/dist/bin/hq-auth-refresh.js +33 -1
- package/dist/command-catalog.generated.d.ts +138 -6
- package/dist/command-catalog.generated.js +178 -6
- package/dist/command-registration-plan.d.ts +24 -0
- package/dist/command-registration-plan.js +4 -0
- package/dist/commands/anywhere-runtime-flag.d.ts +9 -0
- package/dist/commands/anywhere-runtime-flag.js +22 -0
- package/dist/commands/cloud.js +23 -4
- package/dist/commands/core-derive-trigger-facts-command.d.ts +3 -0
- package/dist/commands/core-derive-trigger-facts-command.js +47 -0
- package/dist/commands/core-derive-trigger-facts-native.d.ts +21 -0
- package/dist/commands/core-derive-trigger-facts-native.js +492 -0
- package/dist/commands/core.js +6 -0
- package/dist/commands/daemon.js +39 -2
- package/dist/commands/db-provision.js +6 -1
- package/dist/commands/doctor.js +8 -4
- package/dist/commands/feedback.d.ts +2 -0
- package/dist/commands/feedback.js +10 -0
- package/dist/commands/install-global.d.ts +46 -0
- package/dist/commands/install-global.js +674 -0
- package/dist/commands/link.d.ts +30 -0
- package/dist/commands/link.js +184 -0
- package/dist/commands/mcp-status.d.ts +6 -0
- package/dist/commands/mcp-status.js +21 -4
- package/dist/commands/pkg-install.js +20 -1
- package/dist/commands/update.d.ts +19 -0
- package/dist/commands/update.js +52 -6
- package/dist/fast-core.d.ts +2 -1
- package/dist/fast-core.js +12 -8
- package/dist/fast-scaffold.js +13 -0
- package/dist/index.js +9 -7
- package/dist/lib/cli-invite-nudge.d.ts +25 -0
- package/dist/lib/cli-invite-nudge.js +152 -0
- package/dist/lib/core-utils/timeout-guard-native-flags.d.ts +6 -0
- package/dist/lib/core-utils/timeout-guard-native-flags.js +30 -0
- package/dist/lib/core-utils/timeout-guard-native.d.ts +0 -3
- package/dist/lib/core-utils/timeout-guard-native.js +4 -30
- package/dist/lib/core-utils/timeout-guard.d.ts +2 -0
- package/dist/lib/core-utils/timeout-guard.js +2 -1
- package/dist/lib/daemon/convergence.d.ts +42 -0
- package/dist/lib/daemon/convergence.js +66 -0
- package/dist/lib/daemon/heartbeat.d.ts +43 -0
- package/dist/lib/daemon/heartbeat.js +70 -0
- package/dist/lib/daemon/ops/journal.d.ts +29 -0
- package/dist/lib/daemon/ops/journal.js +141 -0
- package/dist/lib/daemon/ops/memory.d.ts +20 -0
- package/dist/lib/daemon/ops/memory.js +65 -0
- package/dist/lib/daemon/ops/mesh.d.ts +38 -0
- package/dist/lib/daemon/ops/mesh.js +70 -0
- package/dist/lib/daemon/ops/policy.d.ts +78 -0
- package/dist/lib/daemon/ops/policy.js +283 -0
- package/dist/lib/daemon/protocol.d.ts +68 -0
- package/dist/lib/daemon/protocol.js +154 -0
- package/dist/lib/daemon/run.d.ts +10 -0
- package/dist/lib/daemon/run.js +85 -3
- package/dist/lib/daemon/server.d.ts +36 -0
- package/dist/lib/daemon/server.js +306 -0
- package/dist/lib/daemon/session-registry.d.ts +30 -0
- package/dist/lib/daemon/session-registry.js +42 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +38 -0
- package/dist/lib/doctor/checks/anywhere.js +182 -0
- package/dist/lib/doctor/registry.js +6 -0
- package/dist/lib/first-sync-next-step.d.ts +10 -1
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/install/claude-targets.d.ts +47 -0
- package/dist/lib/install/claude-targets.js +203 -0
- package/dist/lib/install/codex-targets.d.ts +57 -0
- package/dist/lib/install/codex-targets.js +187 -0
- package/dist/lib/mesh/live/session-seq.js +62 -9
- package/dist/lib/registry/normalize-remote.d.ts +21 -0
- package/dist/lib/registry/normalize-remote.js +68 -0
- package/dist/lib/registry/registry.d.ts +75 -0
- package/dist/lib/registry/registry.js +261 -0
- package/dist/main.js +3 -1
- package/dist/utils/cli-telemetry.d.ts +11 -0
- package/dist/utils/cli-telemetry.js +76 -0
- package/dist/utils/cognito-session.d.ts +5 -1
- package/dist/utils/cognito-session.js +8 -12
- package/dist/utils/feedback-diagnostics.d.ts +2 -0
- package/dist/utils/run-bundled-script.d.ts +7 -4
- package/dist/utils/run-bundled-script.js +47 -12
- package/dist/utils/version-check.d.ts +2 -0
- package/dist/utils/version-check.js +7 -0
- package/package.json +2 -2
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory.get / memory.put ops (hq-anywhere US-009): session-scoped notes
|
|
3
|
+
* persisted at workspace/sessions/<sid>/hqd-memory.json under HQ_ROOT.
|
|
4
|
+
*/
|
|
5
|
+
export declare const MEMORY_FILE = "hqd-memory.json";
|
|
6
|
+
export declare class MemoryOpError extends Error {
|
|
7
|
+
readonly code: string;
|
|
8
|
+
constructor(code: string, message: string);
|
|
9
|
+
}
|
|
10
|
+
export declare function memoryPath(hqRoot: string, sessionId: string): string;
|
|
11
|
+
export declare function memoryPut(hqRoot: string, sessionId: string, key: string, value: unknown, now?: Date): {
|
|
12
|
+
key: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function memoryGet(hqRoot: string, sessionId: string, key?: string): {
|
|
16
|
+
value: unknown;
|
|
17
|
+
} | {
|
|
18
|
+
entries: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=memory.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory.get / memory.put ops (hq-anywhere US-009): session-scoped notes
|
|
3
|
+
* persisted at workspace/sessions/<sid>/hqd-memory.json under HQ_ROOT.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
const SESSION_RE = /^[A-Za-z0-9._-]+$/;
|
|
8
|
+
export const MEMORY_FILE = "hqd-memory.json";
|
|
9
|
+
const MAX_VALUE_BYTES = 256 * 1024;
|
|
10
|
+
export class MemoryOpError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "MemoryOpError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function memoryPath(hqRoot, sessionId) {
|
|
19
|
+
if (!SESSION_RE.test(sessionId) || sessionId === "." || sessionId === "..") {
|
|
20
|
+
throw new MemoryOpError("bad_args", "sessionId may only contain letters, digits, '.', '_' and '-'");
|
|
21
|
+
}
|
|
22
|
+
return path.join(hqRoot, "workspace", "sessions", sessionId, MEMORY_FILE);
|
|
23
|
+
}
|
|
24
|
+
function readAll(file) {
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = fs.readFileSync(file, "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (err.code === "ENOENT")
|
|
31
|
+
return {};
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
36
|
+
throw new MemoryOpError("corrupt_memory", `${file} is not a JSON object`);
|
|
37
|
+
}
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
export function memoryPut(hqRoot, sessionId, key, value, now = new Date()) {
|
|
41
|
+
if (!key.trim())
|
|
42
|
+
throw new MemoryOpError("bad_args", "key must be non-empty");
|
|
43
|
+
const serialized = JSON.stringify(value ?? null);
|
|
44
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_VALUE_BYTES)
|
|
45
|
+
throw new MemoryOpError("too_large", `value is over ${MAX_VALUE_BYTES} bytes`);
|
|
46
|
+
const file = memoryPath(hqRoot, sessionId);
|
|
47
|
+
const all = readAll(file);
|
|
48
|
+
const updatedAt = now.toISOString();
|
|
49
|
+
if (value === null || value === undefined)
|
|
50
|
+
delete all[key];
|
|
51
|
+
else
|
|
52
|
+
all[key] = { value, updatedAt };
|
|
53
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
54
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
55
|
+
fs.writeFileSync(tmp, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
56
|
+
fs.renameSync(tmp, file);
|
|
57
|
+
return { key, updatedAt };
|
|
58
|
+
}
|
|
59
|
+
export function memoryGet(hqRoot, sessionId, key) {
|
|
60
|
+
const all = readAll(memoryPath(hqRoot, sessionId));
|
|
61
|
+
if (key !== undefined)
|
|
62
|
+
return { value: all[key]?.value ?? null };
|
|
63
|
+
return { entries: Object.fromEntries(Object.entries(all).map(([k, e]) => [k, e.value])) };
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=memory.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mesh.presence op (hq-anywhere US-009).
|
|
3
|
+
*
|
|
4
|
+
* Writes one schema-valid session event to the Work Mesh Live spool through
|
|
5
|
+
* the same enqueue path as `hq mesh session … --enqueue`. The daemon's mesh
|
|
6
|
+
* drainer service already flushes that spool, so presence from a session in a
|
|
7
|
+
* foreign cwd reaches Work Mesh Live without any new transport.
|
|
8
|
+
*/
|
|
9
|
+
export declare class MeshOpError extends Error {
|
|
10
|
+
readonly code: string;
|
|
11
|
+
constructor(code: string, message: string);
|
|
12
|
+
}
|
|
13
|
+
export interface MeshPresenceArgs {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
harness: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
cwd?: string;
|
|
18
|
+
company?: string;
|
|
19
|
+
project?: string;
|
|
20
|
+
task?: string;
|
|
21
|
+
taskId?: string;
|
|
22
|
+
status?: string;
|
|
23
|
+
summary?: string;
|
|
24
|
+
reason?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface MeshPresenceDeps {
|
|
27
|
+
hqRoot?: string;
|
|
28
|
+
/** Work-mesh root override (tests); defaults to ~/.hq/work-mesh or HQ_WORK_MESH_ROOT. */
|
|
29
|
+
workMeshRoot?: string;
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
now?: () => Date;
|
|
32
|
+
}
|
|
33
|
+
export declare function meshPresence(args: MeshPresenceArgs, deps?: MeshPresenceDeps): {
|
|
34
|
+
eventId: string;
|
|
35
|
+
seq: number;
|
|
36
|
+
spoolPath: string;
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=mesh.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mesh.presence op (hq-anywhere US-009).
|
|
3
|
+
*
|
|
4
|
+
* Writes one schema-valid session event to the Work Mesh Live spool through
|
|
5
|
+
* the same enqueue path as `hq mesh session … --enqueue`. The daemon's mesh
|
|
6
|
+
* drainer service already flushes that spool, so presence from a session in a
|
|
7
|
+
* foreign cwd reaches Work Mesh Live without any new transport.
|
|
8
|
+
*/
|
|
9
|
+
import { CLI_VERSION } from "../../../cli-version.js";
|
|
10
|
+
import { CLI_KIND_TO_SCHEMA, EnqueueValidationError, enqueueSessionEvent } from "../../mesh/live/enqueue.js";
|
|
11
|
+
import { reserveSessionSeq } from "../../mesh/live/session-seq.js";
|
|
12
|
+
const HARNESSES = ["claude-code", "claude-desktop", "codex", "grok", "hq-sessions", "agent-box", "agents-v2"];
|
|
13
|
+
const SCHEMA_KINDS = new Set(Object.values(CLI_KIND_TO_SCHEMA));
|
|
14
|
+
const STATUSES = new Set(["queued", "in_progress", "review", "done"]);
|
|
15
|
+
export class MeshOpError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.name = "MeshOpError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function meshPresence(args, deps = {}) {
|
|
24
|
+
const env = deps.env ?? process.env;
|
|
25
|
+
if (!HARNESSES.includes(args.harness)) {
|
|
26
|
+
throw new MeshOpError("bad_args", `harness must be one of ${HARNESSES.join(", ")}`);
|
|
27
|
+
}
|
|
28
|
+
const kind = (CLI_KIND_TO_SCHEMA[args.kind] ?? (SCHEMA_KINDS.has(args.kind) ? args.kind : undefined));
|
|
29
|
+
if (!kind)
|
|
30
|
+
throw new MeshOpError("bad_args", `kind must be one of ${Object.keys(CLI_KIND_TO_SCHEMA).join(", ")}`);
|
|
31
|
+
if (args.status !== undefined && !STATUSES.has(args.status))
|
|
32
|
+
throw new MeshOpError("bad_args", "status must be queued, in_progress, review or done");
|
|
33
|
+
let seq;
|
|
34
|
+
try {
|
|
35
|
+
seq = reserveSessionSeq(args.sessionId, 1, env);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
throw new MeshOpError("bad_args", `sessionId rejected: ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
let result;
|
|
41
|
+
try {
|
|
42
|
+
result = enqueueSessionEvent({
|
|
43
|
+
kind,
|
|
44
|
+
sessionId: args.sessionId,
|
|
45
|
+
harness: args.harness,
|
|
46
|
+
adapterVersion: CLI_VERSION,
|
|
47
|
+
source: "hooks",
|
|
48
|
+
seq,
|
|
49
|
+
cwd: args.cwd,
|
|
50
|
+
hqRoot: deps.hqRoot,
|
|
51
|
+
companySlug: args.company,
|
|
52
|
+
project: args.project,
|
|
53
|
+
task: args.task,
|
|
54
|
+
taskId: args.taskId,
|
|
55
|
+
status: args.status,
|
|
56
|
+
summary: args.summary,
|
|
57
|
+
reason: args.reason,
|
|
58
|
+
root: deps.workMeshRoot,
|
|
59
|
+
env,
|
|
60
|
+
now: deps.now,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (err instanceof EnqueueValidationError)
|
|
65
|
+
throw new MeshOpError("bad_args", err.message);
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
return { eventId: result.event.eventId, seq, spoolPath: result.spoolPath };
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=mesh.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policy.check and policy.index ops (hq-anywhere US-009).
|
|
3
|
+
*
|
|
4
|
+
* Verdicts come from the hq-core hook scripts under HQ_ROOT, run as child
|
|
5
|
+
* processes with a synthesized hook payload, so hookless hosts and sub-agents
|
|
6
|
+
* get the same answer a hook-dispatching session would. No rule is
|
|
7
|
+
* reimplemented here:
|
|
8
|
+
* - .claude/hooks/mandatory-scope-authorizer.sh decides allow/block
|
|
9
|
+
* (exit 2 = block, reason on stderr).
|
|
10
|
+
* - .claude/hooks/inject-policy-on-trigger.sh supplies additionalContext.
|
|
11
|
+
*
|
|
12
|
+
* The policy index (which policy files exist for a company) is cached
|
|
13
|
+
* in-process and invalidated by fs.watch on core/policies, personal/policies
|
|
14
|
+
* and companies/<co>/policies.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SCOPE_AUTHORIZER = ".claude/hooks/mandatory-scope-authorizer.sh";
|
|
17
|
+
export declare const POLICY_TRIGGER = ".claude/hooks/inject-policy-on-trigger.sh";
|
|
18
|
+
export declare class PolicyOpError extends Error {
|
|
19
|
+
readonly code: string;
|
|
20
|
+
constructor(code: string, message: string);
|
|
21
|
+
}
|
|
22
|
+
export interface PolicyEntry {
|
|
23
|
+
slug: string;
|
|
24
|
+
path: string;
|
|
25
|
+
scope: "core" | "personal" | "company";
|
|
26
|
+
company?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Cached list of policy files under HQ_ROOT, invalidated by fs.watch. */
|
|
29
|
+
export declare class PolicyIndex {
|
|
30
|
+
readonly hqRoot: string;
|
|
31
|
+
private readonly log;
|
|
32
|
+
private entries;
|
|
33
|
+
private watchers;
|
|
34
|
+
private closed;
|
|
35
|
+
/** Bumped on every invalidation; lets callers see that the cache was rebuilt. */
|
|
36
|
+
version: number;
|
|
37
|
+
constructor(hqRoot: string, log?: (level: "info" | "warn" | "error", message: string) => void);
|
|
38
|
+
/** Policies visible to `company`: core, personal, and that company only. */
|
|
39
|
+
list(company?: string): PolicyEntry[];
|
|
40
|
+
invalidate(): void;
|
|
41
|
+
close(): void;
|
|
42
|
+
private rebuild;
|
|
43
|
+
private rewatch;
|
|
44
|
+
}
|
|
45
|
+
export interface PolicyCheckArgs {
|
|
46
|
+
company: string;
|
|
47
|
+
event?: string;
|
|
48
|
+
tool: string;
|
|
49
|
+
input?: Record<string, unknown>;
|
|
50
|
+
sessionId?: string;
|
|
51
|
+
cwd?: string;
|
|
52
|
+
}
|
|
53
|
+
export interface PolicyCheckResult {
|
|
54
|
+
decision: "allow" | "block";
|
|
55
|
+
reasons: string[];
|
|
56
|
+
additionalContext: string;
|
|
57
|
+
session: string;
|
|
58
|
+
policies: {
|
|
59
|
+
count: number;
|
|
60
|
+
version: number;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The scope authorizer reads the session's company from
|
|
65
|
+
* workspace/sessions/<sid>/meta.yaml (or scope-capability.json). hqd binds a
|
|
66
|
+
* session id of its own per company + caller, so a check never borrows the
|
|
67
|
+
* binding of an unrelated session.
|
|
68
|
+
*/
|
|
69
|
+
export declare function bindCheckSession(hqRoot: string, company: string, callerSession: string | undefined, now: Date): string;
|
|
70
|
+
export interface PolicyCheckDeps {
|
|
71
|
+
hqRoot: string;
|
|
72
|
+
index: PolicyIndex;
|
|
73
|
+
env?: NodeJS.ProcessEnv;
|
|
74
|
+
now?: () => Date;
|
|
75
|
+
timeoutMs?: number;
|
|
76
|
+
}
|
|
77
|
+
export declare function policyCheck(args: PolicyCheckArgs, deps: PolicyCheckDeps): Promise<PolicyCheckResult>;
|
|
78
|
+
//# sourceMappingURL=policy.d.ts.map
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policy.check and policy.index ops (hq-anywhere US-009).
|
|
3
|
+
*
|
|
4
|
+
* Verdicts come from the hq-core hook scripts under HQ_ROOT, run as child
|
|
5
|
+
* processes with a synthesized hook payload, so hookless hosts and sub-agents
|
|
6
|
+
* get the same answer a hook-dispatching session would. No rule is
|
|
7
|
+
* reimplemented here:
|
|
8
|
+
* - .claude/hooks/mandatory-scope-authorizer.sh decides allow/block
|
|
9
|
+
* (exit 2 = block, reason on stderr).
|
|
10
|
+
* - .claude/hooks/inject-policy-on-trigger.sh supplies additionalContext.
|
|
11
|
+
*
|
|
12
|
+
* The policy index (which policy files exist for a company) is cached
|
|
13
|
+
* in-process and invalidated by fs.watch on core/policies, personal/policies
|
|
14
|
+
* and companies/<co>/policies.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
export const SCOPE_AUTHORIZER = ".claude/hooks/mandatory-scope-authorizer.sh";
|
|
20
|
+
export const POLICY_TRIGGER = ".claude/hooks/inject-policy-on-trigger.sh";
|
|
21
|
+
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
22
|
+
const SESSION_RE = /^[A-Za-z0-9._-]+$/;
|
|
23
|
+
export class PolicyOpError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.name = "PolicyOpError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Cached list of policy files under HQ_ROOT, invalidated by fs.watch. */
|
|
32
|
+
export class PolicyIndex {
|
|
33
|
+
hqRoot;
|
|
34
|
+
log;
|
|
35
|
+
entries = null;
|
|
36
|
+
watchers = [];
|
|
37
|
+
closed = false;
|
|
38
|
+
/** Bumped on every invalidation; lets callers see that the cache was rebuilt. */
|
|
39
|
+
version = 0;
|
|
40
|
+
constructor(hqRoot, log = () => { }) {
|
|
41
|
+
this.hqRoot = hqRoot;
|
|
42
|
+
this.log = log;
|
|
43
|
+
}
|
|
44
|
+
/** Policies visible to `company`: core, personal, and that company only. */
|
|
45
|
+
list(company) {
|
|
46
|
+
if (!this.entries)
|
|
47
|
+
this.rebuild();
|
|
48
|
+
return this.entries.filter((e) => e.scope !== "company" || e.company === company);
|
|
49
|
+
}
|
|
50
|
+
invalidate() {
|
|
51
|
+
if (this.entries === null)
|
|
52
|
+
return;
|
|
53
|
+
this.entries = null;
|
|
54
|
+
this.version += 1;
|
|
55
|
+
}
|
|
56
|
+
close() {
|
|
57
|
+
this.closed = true;
|
|
58
|
+
for (const w of this.watchers)
|
|
59
|
+
w.close();
|
|
60
|
+
this.watchers = [];
|
|
61
|
+
}
|
|
62
|
+
rebuild() {
|
|
63
|
+
const dirs = [
|
|
64
|
+
{ dir: path.join(this.hqRoot, "core", "policies"), scope: "core" },
|
|
65
|
+
{ dir: path.join(this.hqRoot, "personal", "policies"), scope: "personal" },
|
|
66
|
+
];
|
|
67
|
+
const companiesDir = path.join(this.hqRoot, "companies");
|
|
68
|
+
const companyDirs = [];
|
|
69
|
+
for (const co of readDirNames(companiesDir)) {
|
|
70
|
+
if (!SLUG_RE.test(co))
|
|
71
|
+
continue;
|
|
72
|
+
const companyDir = path.join(companiesDir, co);
|
|
73
|
+
if (isDir(companyDir))
|
|
74
|
+
companyDirs.push(companyDir);
|
|
75
|
+
const dir = path.join(companiesDir, co, "policies");
|
|
76
|
+
if (isDir(dir))
|
|
77
|
+
dirs.push({ dir, scope: "company", company: co });
|
|
78
|
+
}
|
|
79
|
+
const entries = [];
|
|
80
|
+
for (const d of dirs) {
|
|
81
|
+
for (const name of readDirNames(d.dir)) {
|
|
82
|
+
if (!name.endsWith(".md") || name.startsWith("_"))
|
|
83
|
+
continue;
|
|
84
|
+
entries.push({ slug: name.slice(0, -3), path: path.join(d.dir, name), scope: d.scope, ...(d.company ? { company: d.company } : {}) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
this.entries = entries;
|
|
88
|
+
this.rewatch([...dirs.map((d) => d.dir), ...companyDirs, companiesDir]);
|
|
89
|
+
}
|
|
90
|
+
rewatch(dirs) {
|
|
91
|
+
for (const w of this.watchers)
|
|
92
|
+
w.close();
|
|
93
|
+
this.watchers = [];
|
|
94
|
+
if (this.closed)
|
|
95
|
+
return;
|
|
96
|
+
for (const dir of dirs) {
|
|
97
|
+
if (!isDir(dir))
|
|
98
|
+
continue;
|
|
99
|
+
try {
|
|
100
|
+
const w = fs.watch(dir, () => this.invalidate());
|
|
101
|
+
w.on("error", (err) => {
|
|
102
|
+
this.log("warn", `policy index watch on ${dir} failed: ${err.message}`);
|
|
103
|
+
this.invalidate();
|
|
104
|
+
});
|
|
105
|
+
this.watchers.push(w);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
this.log("warn", `could not watch ${dir}: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function readDirNames(dir) {
|
|
114
|
+
try {
|
|
115
|
+
return fs.readdirSync(dir);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code === "ENOENT" || err.code === "ENOTDIR")
|
|
119
|
+
return [];
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function isDir(dir) {
|
|
124
|
+
try {
|
|
125
|
+
return fs.statSync(dir).isDirectory();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function runScript(script, payload, env, cwd, timeoutMs) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const child = spawn("bash", [script], { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
134
|
+
let stdout = "";
|
|
135
|
+
let stderr = "";
|
|
136
|
+
let timedOut = false;
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
timedOut = true;
|
|
139
|
+
child.kill("SIGKILL");
|
|
140
|
+
}, timeoutMs);
|
|
141
|
+
child.stdout.on("data", (c) => (stdout += c.toString("utf8")));
|
|
142
|
+
child.stderr.on("data", (c) => (stderr += c.toString("utf8")));
|
|
143
|
+
child.on("error", (err) => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
reject(err);
|
|
146
|
+
});
|
|
147
|
+
child.on("close", (code) => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
150
|
+
});
|
|
151
|
+
child.stdin.on("error", () => {
|
|
152
|
+
// The script may exit before reading stdin; its exit code still decides.
|
|
153
|
+
});
|
|
154
|
+
child.stdin.end(payload);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The scope authorizer reads the session's company from
|
|
159
|
+
* workspace/sessions/<sid>/meta.yaml (or scope-capability.json). hqd binds a
|
|
160
|
+
* session id of its own per company + caller, so a check never borrows the
|
|
161
|
+
* binding of an unrelated session.
|
|
162
|
+
*/
|
|
163
|
+
export function bindCheckSession(hqRoot, company, callerSession, now) {
|
|
164
|
+
const caller = (callerSession ?? "default").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80) || "default";
|
|
165
|
+
const sid = `hqd-${company}-${caller}`;
|
|
166
|
+
if (!SESSION_RE.test(sid))
|
|
167
|
+
throw new PolicyOpError("bad_args", "sessionId has no usable characters");
|
|
168
|
+
const dir = path.join(hqRoot, "workspace", "sessions", sid);
|
|
169
|
+
const meta = path.join(dir, "meta.yaml");
|
|
170
|
+
const body = `session_id: ${sid}\nstarted_at: ${now.toISOString()}\nsenior: user\ncompany_slug: ${company}\nsource: hqd\n`;
|
|
171
|
+
let current = "";
|
|
172
|
+
try {
|
|
173
|
+
current = fs.readFileSync(meta, "utf8");
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
if (err.code !== "ENOENT")
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
if (!current.includes(`\ncompany_slug: ${company}\n`)) {
|
|
180
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
181
|
+
const tmp = `${meta}.${process.pid}.tmp`;
|
|
182
|
+
fs.writeFileSync(tmp, body);
|
|
183
|
+
fs.renameSync(tmp, meta);
|
|
184
|
+
}
|
|
185
|
+
return sid;
|
|
186
|
+
}
|
|
187
|
+
function extractContext(stdout) {
|
|
188
|
+
const text = stdout.trim();
|
|
189
|
+
if (!text)
|
|
190
|
+
return "";
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(text);
|
|
193
|
+
const ctx = parsed.hookSpecificOutput?.additionalContext ?? parsed.additionalContext;
|
|
194
|
+
return typeof ctx === "string" ? ctx : "";
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return text;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function blockFromJson(stdout) {
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(stdout.trim());
|
|
203
|
+
if (parsed.decision === "block")
|
|
204
|
+
return typeof parsed.reason === "string" ? parsed.reason : "blocked";
|
|
205
|
+
if (parsed.hookSpecificOutput?.permissionDecision === "deny") {
|
|
206
|
+
const r = parsed.hookSpecificOutput.permissionDecisionReason;
|
|
207
|
+
return typeof r === "string" ? r : "denied";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Plain-text stdout is not a verdict.
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
export async function policyCheck(args, deps) {
|
|
216
|
+
const { hqRoot } = deps;
|
|
217
|
+
if (!SLUG_RE.test(args.company))
|
|
218
|
+
throw new PolicyOpError("bad_args", "company must be a company slug");
|
|
219
|
+
if (!isDir(path.join(hqRoot, "companies", args.company))) {
|
|
220
|
+
throw new PolicyOpError("unknown_company", `no companies/${args.company} under ${hqRoot}`);
|
|
221
|
+
}
|
|
222
|
+
const authorizer = path.join(hqRoot, SCOPE_AUTHORIZER);
|
|
223
|
+
const trigger = path.join(hqRoot, POLICY_TRIGGER);
|
|
224
|
+
if (!fs.existsSync(authorizer)) {
|
|
225
|
+
throw new PolicyOpError("policy_engine_unavailable", `scope authorizer not found at ${authorizer}`);
|
|
226
|
+
}
|
|
227
|
+
const event = args.event ?? "PreToolUse";
|
|
228
|
+
const session = bindCheckSession(hqRoot, args.company, args.sessionId, (deps.now ?? (() => new Date()))());
|
|
229
|
+
const payload = JSON.stringify({
|
|
230
|
+
session_id: session,
|
|
231
|
+
hook_event_name: event,
|
|
232
|
+
tool_name: args.tool,
|
|
233
|
+
tool_input: args.input ?? {},
|
|
234
|
+
cwd: args.cwd ?? hqRoot,
|
|
235
|
+
});
|
|
236
|
+
const env = {
|
|
237
|
+
...(deps.env ?? process.env),
|
|
238
|
+
HQ_ROOT: hqRoot,
|
|
239
|
+
CLAUDE_PROJECT_DIR: hqRoot,
|
|
240
|
+
HQ_POLICY_COMPANY: args.company,
|
|
241
|
+
BASH_ENV: "/dev/null",
|
|
242
|
+
};
|
|
243
|
+
delete env.HQ_HOOK_SESSION_ID;
|
|
244
|
+
delete env.HQ_HOOK_TOOL_NAME;
|
|
245
|
+
const timeoutMs = deps.timeoutMs ?? 15_000;
|
|
246
|
+
const reasons = [];
|
|
247
|
+
let decision = "allow";
|
|
248
|
+
if (event === "PreToolUse") {
|
|
249
|
+
const r = await runScript(authorizer, payload, env, hqRoot, timeoutMs);
|
|
250
|
+
const jsonBlock = blockFromJson(r.stdout);
|
|
251
|
+
if (r.timedOut) {
|
|
252
|
+
decision = "block";
|
|
253
|
+
reasons.push(`scope authorizer timed out after ${timeoutMs}ms; failing closed`);
|
|
254
|
+
}
|
|
255
|
+
else if (r.code === 2 || jsonBlock) {
|
|
256
|
+
decision = "block";
|
|
257
|
+
reasons.push((jsonBlock ?? r.stderr).trim() || "blocked by scope authorizer");
|
|
258
|
+
}
|
|
259
|
+
else if (r.code !== 0) {
|
|
260
|
+
decision = "block";
|
|
261
|
+
reasons.push(`scope authorizer exited ${r.code}; failing closed: ${r.stderr.trim()}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
let additionalContext = "";
|
|
265
|
+
if (fs.existsSync(trigger)) {
|
|
266
|
+
const r = await runScript(trigger, payload, env, hqRoot, timeoutMs);
|
|
267
|
+
if (r.code === 0 && !r.timedOut)
|
|
268
|
+
additionalContext = extractContext(r.stdout);
|
|
269
|
+
else
|
|
270
|
+
reasons.push(`policy trigger hook did not complete (exit ${r.code}${r.timedOut ? ", timed out" : ""}); no policy context`);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
reasons.push(`policy trigger hook not found at ${trigger}; no policy context`);
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
decision,
|
|
277
|
+
reasons,
|
|
278
|
+
additionalContext,
|
|
279
|
+
session,
|
|
280
|
+
policies: { count: deps.index.list(args.company).length, version: deps.index.version },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
//# sourceMappingURL=policy.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hqd wire protocol (hq-anywhere US-008).
|
|
3
|
+
*
|
|
4
|
+
* One Unix domain socket (a named pipe on Windows) carrying newline-delimited
|
|
5
|
+
* JSON. Requests are `{id, op, args}`; each gets exactly one response
|
|
6
|
+
* `{id, ok: true, result}` or `{id, ok: false, error}`. Responses may arrive
|
|
7
|
+
* out of order, so clients match them by `id`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const HQD_SOCKET_NAME = "hqd.sock";
|
|
10
|
+
export declare const HQD_OPS: readonly ["ping", "resolve", "session.open", "session.get", "session.close", "policy.check", "policy.index", "journal.append", "memory.get", "memory.put", "mesh.presence", "shutdown"];
|
|
11
|
+
export type HqdOp = (typeof HQD_OPS)[number];
|
|
12
|
+
/** A request line larger than this is rejected and the connection closed. */
|
|
13
|
+
export declare const HQD_MAX_LINE_BYTES: number;
|
|
14
|
+
export interface HqdRequest {
|
|
15
|
+
id: string | number;
|
|
16
|
+
op: string;
|
|
17
|
+
args?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
export interface HqdError {
|
|
20
|
+
code: string;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
export type HqdResponse = {
|
|
24
|
+
id: string | number | null;
|
|
25
|
+
ok: true;
|
|
26
|
+
result: unknown;
|
|
27
|
+
} | {
|
|
28
|
+
id: string | number | null;
|
|
29
|
+
ok: false;
|
|
30
|
+
error: HqdError;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
|
|
34
|
+
* On Windows the socket is a per-user named pipe.
|
|
35
|
+
*/
|
|
36
|
+
export declare function hqdSocketPath(home?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
|
|
37
|
+
export declare function encodeMessage(message: HqdRequest | HqdResponse): string;
|
|
38
|
+
export type ParsedLine = {
|
|
39
|
+
ok: true;
|
|
40
|
+
request: HqdRequest;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
id: string | number | null;
|
|
44
|
+
error: HqdError;
|
|
45
|
+
};
|
|
46
|
+
/** Validate one request line. Never throws. */
|
|
47
|
+
export declare function parseRequestLine(line: string): ParsedLine;
|
|
48
|
+
/**
|
|
49
|
+
* Split a byte stream into lines. Returns a push function that hands each
|
|
50
|
+
* complete line to `onLine`, and reports overflow when a partial line grows
|
|
51
|
+
* past `maxBytes`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createLineSplitter(onLine: (line: string) => void, onOverflow: () => void, maxBytes?: number): (chunk: Buffer | string) => void;
|
|
54
|
+
export declare class HqdClientError extends Error {
|
|
55
|
+
readonly code: string;
|
|
56
|
+
constructor(code: string, message: string);
|
|
57
|
+
}
|
|
58
|
+
export interface HqdRequestOptions {
|
|
59
|
+
socketPath?: string;
|
|
60
|
+
/** Whole-request budget, including connect. Default 2000ms. */
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Send one request and wait for its response. A daemon that is not running
|
|
65
|
+
* fails fast with the socket error code (ENOENT or ECONNREFUSED).
|
|
66
|
+
*/
|
|
67
|
+
export declare function hqdRequest(op: HqdOp | string, args?: Record<string, unknown>, opts?: HqdRequestOptions): Promise<unknown>;
|
|
68
|
+
//# sourceMappingURL=protocol.d.ts.map
|