@scopebond/hook 0.8.1 → 0.10.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.
Files changed (110) hide show
  1. package/README.md +239 -0
  2. package/dist/budget-load.d.ts +68 -0
  3. package/dist/budget-load.d.ts.map +1 -0
  4. package/dist/budget-load.js +164 -0
  5. package/dist/budget-load.js.map +1 -0
  6. package/dist/capabilities.d.ts +103 -0
  7. package/dist/capabilities.d.ts.map +1 -0
  8. package/dist/capabilities.js +212 -0
  9. package/dist/capabilities.js.map +1 -0
  10. package/dist/classify.d.ts +17 -0
  11. package/dist/classify.d.ts.map +1 -0
  12. package/dist/classify.js +78 -0
  13. package/dist/classify.js.map +1 -0
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +585 -13
  16. package/dist/cli.js.map +1 -1
  17. package/dist/cloud.d.ts +8 -0
  18. package/dist/cloud.d.ts.map +1 -1
  19. package/dist/cloud.js.map +1 -1
  20. package/dist/dispatch-cli.d.ts +2 -0
  21. package/dist/dispatch-cli.d.ts.map +1 -0
  22. package/dist/dispatch-cli.js +128 -0
  23. package/dist/dispatch-cli.js.map +1 -0
  24. package/dist/explain.d.ts.map +1 -1
  25. package/dist/explain.js +2 -1
  26. package/dist/explain.js.map +1 -1
  27. package/dist/group.d.ts +12 -0
  28. package/dist/group.d.ts.map +1 -0
  29. package/dist/group.js +55 -0
  30. package/dist/group.js.map +1 -0
  31. package/dist/index.d.ts +29 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +19 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.d.ts +10 -0
  36. package/dist/install.d.ts.map +1 -1
  37. package/dist/install.js +56 -0
  38. package/dist/install.js.map +1 -1
  39. package/dist/managed.d.ts +69 -0
  40. package/dist/managed.d.ts.map +1 -0
  41. package/dist/managed.js +184 -0
  42. package/dist/managed.js.map +1 -0
  43. package/dist/map.d.ts +4 -0
  44. package/dist/map.d.ts.map +1 -1
  45. package/dist/map.js +29 -1
  46. package/dist/map.js.map +1 -1
  47. package/dist/obs-emitter.d.ts +132 -0
  48. package/dist/obs-emitter.d.ts.map +1 -0
  49. package/dist/obs-emitter.js +415 -0
  50. package/dist/obs-emitter.js.map +1 -0
  51. package/dist/obs-store.d.ts +160 -0
  52. package/dist/obs-store.d.ts.map +1 -0
  53. package/dist/obs-store.js +367 -0
  54. package/dist/obs-store.js.map +1 -0
  55. package/dist/obs-upload.d.ts +24 -0
  56. package/dist/obs-upload.d.ts.map +1 -0
  57. package/dist/obs-upload.js +231 -0
  58. package/dist/obs-upload.js.map +1 -0
  59. package/dist/observation.d.ts +232 -0
  60. package/dist/observation.d.ts.map +1 -0
  61. package/dist/observation.js +308 -0
  62. package/dist/observation.js.map +1 -0
  63. package/dist/paths.d.ts +15 -0
  64. package/dist/paths.d.ts.map +1 -0
  65. package/dist/paths.js +79 -0
  66. package/dist/paths.js.map +1 -0
  67. package/dist/policy-load.d.ts +60 -0
  68. package/dist/policy-load.d.ts.map +1 -0
  69. package/dist/policy-load.js +151 -0
  70. package/dist/policy-load.js.map +1 -0
  71. package/dist/policy-sync.d.ts +45 -0
  72. package/dist/policy-sync.d.ts.map +1 -0
  73. package/dist/policy-sync.js +171 -0
  74. package/dist/policy-sync.js.map +1 -0
  75. package/dist/proof.d.ts +30 -0
  76. package/dist/proof.d.ts.map +1 -0
  77. package/dist/proof.js +186 -0
  78. package/dist/proof.js.map +1 -0
  79. package/dist/rules.d.ts +20 -0
  80. package/dist/rules.d.ts.map +1 -1
  81. package/dist/rules.js +50 -0
  82. package/dist/rules.js.map +1 -1
  83. package/dist/runtime.d.ts +21 -1
  84. package/dist/runtime.d.ts.map +1 -1
  85. package/dist/runtime.js +77 -10
  86. package/dist/runtime.js.map +1 -1
  87. package/dist/scan.d.ts +7 -0
  88. package/dist/scan.d.ts.map +1 -0
  89. package/dist/scan.js +38 -0
  90. package/dist/scan.js.map +1 -0
  91. package/dist/shell.d.ts.map +1 -1
  92. package/dist/shell.js +12 -3
  93. package/dist/shell.js.map +1 -1
  94. package/dist/sql-classify.d.ts +11 -0
  95. package/dist/sql-classify.d.ts.map +1 -0
  96. package/dist/sql-classify.js +251 -0
  97. package/dist/sql-classify.js.map +1 -0
  98. package/dist/typed-infra.d.ts +73 -0
  99. package/dist/typed-infra.d.ts.map +1 -0
  100. package/dist/typed-infra.js +758 -0
  101. package/dist/typed-infra.js.map +1 -0
  102. package/dist/typed-ops.d.ts +140 -0
  103. package/dist/typed-ops.d.ts.map +1 -0
  104. package/dist/typed-ops.js +549 -0
  105. package/dist/typed-ops.js.map +1 -0
  106. package/dist/vectors.d.ts +40 -0
  107. package/dist/vectors.d.ts.map +1 -0
  108. package/dist/vectors.js +219 -0
  109. package/dist/vectors.js.map +1 -0
  110. package/package.json +5 -5
@@ -0,0 +1,151 @@
1
+ // Load a policy exported from a Scopebond Cloud workspace, and say honestly what happened.
2
+ //
3
+ // An export is a reviewed policy in a file. Nothing is enforcing it until a hook loads it, and
4
+ // the workspace shows it as "pending" until a signed acknowledgement echoes exactly what the
5
+ // export said: its policy digest, its policy id and version, its scope digest and the export
6
+ // id. This module checks an export and, when asked, installs it as the active policy; the
7
+ // caller then queues the acknowledgement (`ObservationEmitter.policyAck`).
8
+ //
9
+ // What is checked, and what is not:
10
+ // - the export's own integrity: its `policy_hash` must be the SHA-256 of the canonical policy
11
+ // it carries, and its `scope.scope_digest` must be the SHA-256 of
12
+ // `scopebond:policy-scope/v1\n` + the canonical `{agent_id, environment_id, export_id}`;
13
+ // - that it is for this workspace environment (when this machine is connected);
14
+ // - that the gateway can build a runtime from it.
15
+ // An export carries no signature of its own, so this cannot prove who wrote it: get the file
16
+ // from your workspace, over the workspace's own page or API.
17
+ //
18
+ // Loading replaces the active policy. The previous file is kept beside it, and the write is
19
+ // atomic (a temp file in the same directory, then a rename), so an interrupted load leaves the
20
+ // old policy in place. Nothing here touches an agent's settings.
21
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { createHash, randomBytes } from "node:crypto";
25
+ import { canonical } from "@scopebond/policy-schema/canonical";
26
+ import { scaffold } from "./init.js";
27
+ import { createHookRuntime } from "./runtime.js";
28
+ import { digestPolicy } from "./observation.js";
29
+ export const POLICY_SCOPE_DOMAIN = "scopebond:policy-scope/v1\n";
30
+ export const MAX_EXPORT_BYTES = 1024 * 1024;
31
+ export const LOADED_POLICY_FILE = "loaded-policy.json";
32
+ export const PREVIOUS_POLICY_FILE = "policy.previous.json";
33
+ /** SHA-256 of the domain plus the canonical scope an export was made for. */
34
+ export const policyScopeDigest = (scope) => createHash("sha256").update(POLICY_SCOPE_DOMAIN + canonical({ agent_id: scope.agent_id, environment_id: scope.environment_id, export_id: scope.export_id }), "utf8").digest("hex");
35
+ const HEX64 = /^[0-9a-f]{64}$/;
36
+ const OPAQUE = /^[\x21-\x7e]{1,200}$/;
37
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
38
+ /** Accept the export itself, or the workspace's `{ id, configuration }` answer around it. */
39
+ export function unwrapExport(raw) {
40
+ if (!isObject(raw))
41
+ return null;
42
+ if (raw.type === undefined && isObject(raw.configuration))
43
+ return raw.configuration;
44
+ return raw;
45
+ }
46
+ /** Check an export without reading or changing anything on disk. Pure. */
47
+ export function inspectExport(raw, context = {}) {
48
+ const exp = unwrapExport(raw);
49
+ if (!exp || exp.type !== "scopebond:reviewed-policy-export")
50
+ return { ok: false, error: "schema_invalid", message: "this is not a Scopebond policy export" };
51
+ if (exp.version !== 1)
52
+ return { ok: false, error: "unsupported", message: `unsupported export version ${String(exp.version)}` };
53
+ const scope = exp.scope;
54
+ const policy = exp.policy;
55
+ if (!isObject(scope) || !isObject(policy) || typeof exp.policy_hash !== "string" || !HEX64.test(exp.policy_hash)) {
56
+ return { ok: false, error: "schema_invalid", message: "the export lacks a policy, a policy hash or a scope block (an export made before scope blocks existed cannot be acknowledged; export it again from the workspace)" };
57
+ }
58
+ const { export_id: exportId, policy_id: policyId, policy_version: policyVersion, agent_id: agentId, environment_id: environmentId, scope_digest: scopeDigest } = scope;
59
+ if (typeof exportId !== "string" || !OPAQUE.test(exportId) || typeof policyId !== "string" || !OPAQUE.test(policyId)
60
+ || !Number.isInteger(policyVersion) || policyVersion < 1 || policyVersion > 2_147_483_647
61
+ || typeof agentId !== "string" || !OPAQUE.test(agentId) || typeof environmentId !== "string" || !OPAQUE.test(environmentId)
62
+ || typeof scopeDigest !== "string" || !HEX64.test(scopeDigest)) {
63
+ return { ok: false, error: "schema_invalid", message: "the export's scope block is incomplete or malformed" };
64
+ }
65
+ // The values below are echoed exactly as the export states them, so a rejection still names the export.
66
+ const echo = { exportId, policyId, policyVersion: policyVersion, policyDigest: exp.policy_hash, scopeDigest };
67
+ if (digestPolicy(policy) !== exp.policy_hash) {
68
+ return { ok: false, error: "signature_invalid", message: "the policy does not match the export's policy hash; the file was changed or damaged", ack: echo };
69
+ }
70
+ if (policyScopeDigest({ export_id: exportId, agent_id: agentId, environment_id: environmentId }) !== scopeDigest) {
71
+ return { ok: false, error: "scope_mismatch", message: "the export's scope digest does not match its export, agent and environment", ack: echo };
72
+ }
73
+ if (context.environmentId !== undefined && context.environmentId !== environmentId) {
74
+ return { ok: false, error: "scope_mismatch", message: "this export is for a different environment than this machine is connected to", ack: echo };
75
+ }
76
+ return { ok: true, facts: { ...echo, agentId, environmentId, policy } };
77
+ }
78
+ /** Whether the gateway can build a runtime from this policy, in a throwaway home. Never touches a real one. */
79
+ export function policyBuilds(policy) {
80
+ const dir = mkdtempSync(join(tmpdir(), "scopebond-policy-check-"));
81
+ let runtime;
82
+ try {
83
+ scaffold(dir);
84
+ writeFileSync(join(dir, "policy.json"), `${JSON.stringify(policy)}\n`);
85
+ runtime = createHookRuntime({ policyPath: join(dir, "policy.json"), keyPath: join(dir, "agent.key"), attesterPath: join(dir, "attester.key"), dbPath: join(dir, "receipts.db") });
86
+ return true;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ finally {
92
+ try {
93
+ runtime?.close();
94
+ }
95
+ catch { /* removed next */ }
96
+ try {
97
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5 });
98
+ }
99
+ catch { /* the OS temp dir is cleaned later */ }
100
+ }
101
+ }
102
+ /** Write a file atomically: a temp file in the same directory, then a rename. */
103
+ function writeAtomic(file, text) {
104
+ const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
105
+ try {
106
+ writeFileSync(temp, text, { mode: 0o600 });
107
+ renameSync(temp, file);
108
+ }
109
+ catch (error) {
110
+ try {
111
+ rmSync(temp, { force: true });
112
+ }
113
+ catch { /* nothing to remove */ }
114
+ throw error;
115
+ }
116
+ }
117
+ /** Read an export file, check it, and (with `apply`) install it as the active policy in `dir`. */
118
+ export function loadPolicyExport(dir, file, options) {
119
+ let raw;
120
+ try {
121
+ if (statSync(file).size > MAX_EXPORT_BYTES)
122
+ return { state: "rejected", error: "schema_invalid", message: "the export file is larger than 1 MiB" };
123
+ raw = JSON.parse(readFileSync(file, "utf8"));
124
+ }
125
+ catch (error) {
126
+ return { state: "rejected", error: "schema_invalid", message: `the export could not be read as JSON (${error.name})` };
127
+ }
128
+ const inspected = inspectExport(raw, { environmentId: options.environmentId });
129
+ if (!inspected.ok)
130
+ return { state: "rejected", error: inspected.error, message: inspected.message, ...(inspected.ack ? { ack: inspected.ack } : {}) };
131
+ const { facts } = inspected;
132
+ if (!policyBuilds(facts.policy)) {
133
+ return { state: "rejected", error: "unsupported", message: "this gateway cannot load that policy", ack: { exportId: facts.exportId, policyId: facts.policyId, policyVersion: facts.policyVersion, policyDigest: facts.policyDigest, scopeDigest: facts.scopeDigest } };
134
+ }
135
+ if (!options.apply)
136
+ return { state: "would_load", facts };
137
+ mkdirSync(dir, { recursive: true });
138
+ const policyPath = join(dir, "policy.json");
139
+ let previous = null;
140
+ if (existsSync(policyPath)) {
141
+ previous = join(dir, PREVIOUS_POLICY_FILE);
142
+ copyFileSync(policyPath, previous);
143
+ }
144
+ writeAtomic(policyPath, `${JSON.stringify(facts.policy, null, 2)}\n`);
145
+ writeAtomic(join(dir, LOADED_POLICY_FILE), `${JSON.stringify({
146
+ export_id: facts.exportId, policy_id: facts.policyId, policy_version: facts.policyVersion, policy_digest: facts.policyDigest,
147
+ scope_digest: facts.scopeDigest, environment_id: facts.environmentId, agent_id: facts.agentId, loaded_at: new Date().toISOString(),
148
+ }, null, 2)}\n`);
149
+ return { state: "loaded", facts, policyPath, previous };
150
+ }
151
+ //# sourceMappingURL=policy-load.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy-load.js","sourceRoot":"","sources":["../src/policy-load.ts"],"names":[],"mappings":"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","sourcesContent":["// Load a policy exported from a Scopebond Cloud workspace, and say honestly what happened.\n//\n// An export is a reviewed policy in a file. Nothing is enforcing it until a hook loads it, and\n// the workspace shows it as \"pending\" until a signed acknowledgement echoes exactly what the\n// export said: its policy digest, its policy id and version, its scope digest and the export\n// id. This module checks an export and, when asked, installs it as the active policy; the\n// caller then queues the acknowledgement (`ObservationEmitter.policyAck`).\n//\n// What is checked, and what is not:\n// - the export's own integrity: its `policy_hash` must be the SHA-256 of the canonical policy\n// it carries, and its `scope.scope_digest` must be the SHA-256 of\n// `scopebond:policy-scope/v1\\n` + the canonical `{agent_id, environment_id, export_id}`;\n// - that it is for this workspace environment (when this machine is connected);\n// - that the gateway can build a runtime from it.\n// An export carries no signature of its own, so this cannot prove who wrote it: get the file\n// from your workspace, over the workspace's own page or API.\n//\n// Loading replaces the active policy. The previous file is kept beside it, and the write is\n// atomic (a temp file in the same directory, then a rename), so an interrupted load leaves the\n// old policy in place. Nothing here touches an agent's settings.\n\nimport { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { createHash, randomBytes } from \"node:crypto\";\nimport { canonical } from \"@scopebond/policy-schema/canonical\";\nimport { scaffold } from \"./init.js\";\nimport { createHookRuntime } from \"./runtime.js\";\nimport { digestPolicy, type PolicyAckInput, type PolicyLoadError } from \"./observation.js\";\n\nexport const POLICY_SCOPE_DOMAIN = \"scopebond:policy-scope/v1\\n\";\nexport const MAX_EXPORT_BYTES = 1024 * 1024;\nexport const LOADED_POLICY_FILE = \"loaded-policy.json\";\nexport const PREVIOUS_POLICY_FILE = \"policy.previous.json\";\n\n/** SHA-256 of the domain plus the canonical scope an export was made for. */\nexport const policyScopeDigest = (scope: { export_id: string; agent_id: string; environment_id: string }): string =>\n createHash(\"sha256\").update(POLICY_SCOPE_DOMAIN + canonical({ agent_id: scope.agent_id, environment_id: scope.environment_id, export_id: scope.export_id } as never), \"utf8\").digest(\"hex\");\n\nconst HEX64 = /^[0-9a-f]{64}$/;\nconst OPAQUE = /^[\\x21-\\x7e]{1,200}$/;\n\nexport interface ExportFacts {\n exportId: string;\n policyId: string;\n policyVersion: number;\n policyDigest: string;\n scopeDigest: string;\n agentId: string;\n environmentId: string;\n policy: Record<string, unknown>;\n}\n\nexport type Inspection =\n | { ok: true; facts: ExportFacts }\n /** `ack` is set when the export names enough to echo in a rejection; otherwise nothing is acknowledged. */\n | { ok: false; error: PolicyLoadError; message: string; ack?: Omit<PolicyAckInput, \"error\"> };\n\nconst isObject = (v: unknown): v is Record<string, unknown> => typeof v === \"object\" && v !== null && !Array.isArray(v);\n\n/** Accept the export itself, or the workspace's `{ id, configuration }` answer around it. */\nexport function unwrapExport(raw: unknown): Record<string, unknown> | null {\n if (!isObject(raw)) return null;\n if (raw.type === undefined && isObject(raw.configuration)) return raw.configuration;\n return raw;\n}\n\n/** Check an export without reading or changing anything on disk. Pure. */\nexport function inspectExport(raw: unknown, context: { environmentId?: string } = {}): Inspection {\n const exp = unwrapExport(raw);\n if (!exp || exp.type !== \"scopebond:reviewed-policy-export\") return { ok: false, error: \"schema_invalid\", message: \"this is not a Scopebond policy export\" };\n if (exp.version !== 1) return { ok: false, error: \"unsupported\", message: `unsupported export version ${String(exp.version)}` };\n const scope = exp.scope;\n const policy = exp.policy;\n if (!isObject(scope) || !isObject(policy) || typeof exp.policy_hash !== \"string\" || !HEX64.test(exp.policy_hash)) {\n return { ok: false, error: \"schema_invalid\", message: \"the export lacks a policy, a policy hash or a scope block (an export made before scope blocks existed cannot be acknowledged; export it again from the workspace)\" };\n }\n const { export_id: exportId, policy_id: policyId, policy_version: policyVersion, agent_id: agentId, environment_id: environmentId, scope_digest: scopeDigest } = scope;\n if (typeof exportId !== \"string\" || !OPAQUE.test(exportId) || typeof policyId !== \"string\" || !OPAQUE.test(policyId)\n || !Number.isInteger(policyVersion) || (policyVersion as number) < 1 || (policyVersion as number) > 2_147_483_647\n || typeof agentId !== \"string\" || !OPAQUE.test(agentId) || typeof environmentId !== \"string\" || !OPAQUE.test(environmentId)\n || typeof scopeDigest !== \"string\" || !HEX64.test(scopeDigest)) {\n return { ok: false, error: \"schema_invalid\", message: \"the export's scope block is incomplete or malformed\" };\n }\n // The values below are echoed exactly as the export states them, so a rejection still names the export.\n const echo = { exportId, policyId, policyVersion: policyVersion as number, policyDigest: exp.policy_hash, scopeDigest };\n if (digestPolicy(policy) !== exp.policy_hash) {\n return { ok: false, error: \"signature_invalid\", message: \"the policy does not match the export's policy hash; the file was changed or damaged\", ack: echo };\n }\n if (policyScopeDigest({ export_id: exportId, agent_id: agentId, environment_id: environmentId }) !== scopeDigest) {\n return { ok: false, error: \"scope_mismatch\", message: \"the export's scope digest does not match its export, agent and environment\", ack: echo };\n }\n if (context.environmentId !== undefined && context.environmentId !== environmentId) {\n return { ok: false, error: \"scope_mismatch\", message: \"this export is for a different environment than this machine is connected to\", ack: echo };\n }\n return { ok: true, facts: { ...echo, agentId, environmentId, policy } };\n}\n\n/** Whether the gateway can build a runtime from this policy, in a throwaway home. Never touches a real one. */\nexport function policyBuilds(policy: Record<string, unknown>): boolean {\n const dir = mkdtempSync(join(tmpdir(), \"scopebond-policy-check-\"));\n let runtime: ReturnType<typeof createHookRuntime> | undefined;\n try {\n scaffold(dir);\n writeFileSync(join(dir, \"policy.json\"), `${JSON.stringify(policy)}\\n`);\n runtime = createHookRuntime({ policyPath: join(dir, \"policy.json\"), keyPath: join(dir, \"agent.key\"), attesterPath: join(dir, \"attester.key\"), dbPath: join(dir, \"receipts.db\") });\n return true;\n } catch { return false; }\n finally {\n try { runtime?.close(); } catch { /* removed next */ }\n try { rmSync(dir, { recursive: true, force: true, maxRetries: 5 }); } catch { /* the OS temp dir is cleaned later */ }\n }\n}\n\nexport type LoadOutcome =\n | { state: \"loaded\"; facts: ExportFacts; policyPath: string; previous: string | null }\n | { state: \"would_load\"; facts: ExportFacts }\n | { state: \"rejected\"; error: PolicyLoadError; message: string; ack?: Omit<PolicyAckInput, \"error\"> };\n\n/** Write a file atomically: a temp file in the same directory, then a rename. */\nfunction writeAtomic(file: string, text: string): void {\n const temp = `${file}.${randomBytes(6).toString(\"hex\")}.tmp`;\n try { writeFileSync(temp, text, { mode: 0o600 }); renameSync(temp, file); }\n catch (error) { try { rmSync(temp, { force: true }); } catch { /* nothing to remove */ } throw error; }\n}\n\n/** Read an export file, check it, and (with `apply`) install it as the active policy in `dir`. */\nexport function loadPolicyExport(dir: string, file: string, options: { apply: boolean; environmentId?: string }): LoadOutcome {\n let raw: unknown;\n try {\n if (statSync(file).size > MAX_EXPORT_BYTES) return { state: \"rejected\", error: \"schema_invalid\", message: \"the export file is larger than 1 MiB\" };\n raw = JSON.parse(readFileSync(file, \"utf8\"));\n } catch (error) {\n return { state: \"rejected\", error: \"schema_invalid\", message: `the export could not be read as JSON (${(error as Error).name})` };\n }\n const inspected = inspectExport(raw, { environmentId: options.environmentId });\n if (!inspected.ok) return { state: \"rejected\", error: inspected.error, message: inspected.message, ...(inspected.ack ? { ack: inspected.ack } : {}) };\n const { facts } = inspected;\n if (!policyBuilds(facts.policy)) {\n return { state: \"rejected\", error: \"unsupported\", message: \"this gateway cannot load that policy\", ack: { exportId: facts.exportId, policyId: facts.policyId, policyVersion: facts.policyVersion, policyDigest: facts.policyDigest, scopeDigest: facts.scopeDigest } };\n }\n if (!options.apply) return { state: \"would_load\", facts };\n mkdirSync(dir, { recursive: true });\n const policyPath = join(dir, \"policy.json\");\n let previous: string | null = null;\n if (existsSync(policyPath)) { previous = join(dir, PREVIOUS_POLICY_FILE); copyFileSync(policyPath, previous); }\n writeAtomic(policyPath, `${JSON.stringify(facts.policy, null, 2)}\\n`);\n writeAtomic(join(dir, LOADED_POLICY_FILE), `${JSON.stringify({\n export_id: facts.exportId, policy_id: facts.policyId, policy_version: facts.policyVersion, policy_digest: facts.policyDigest,\n scope_digest: facts.scopeDigest, environment_id: facts.environmentId, agent_id: facts.agentId, loaded_at: new Date().toISOString(),\n }, null, 2)}\\n`);\n return { state: \"loaded\", facts, policyPath, previous };\n}\n"]}
@@ -0,0 +1,45 @@
1
+ import { type RefusalReason } from "./managed.js";
2
+ export declare const SYNC_INTERVAL_MS: number;
3
+ /** How long a tool call may spend on the five-minute check, at most (`SCOPEBOND_POLICY_SYNC_MS` overrides). */
4
+ export declare const INLINE_BUDGET_MS = 1500;
5
+ export type SyncOutcome = {
6
+ state: "not_connected";
7
+ } | {
8
+ state: "own_rules";
9
+ changed: boolean;
10
+ } | {
11
+ state: "unchanged";
12
+ revision: number;
13
+ } | {
14
+ state: "applied";
15
+ revision: number;
16
+ } | {
17
+ state: "refused";
18
+ revision: number | null;
19
+ reason: RefusalReason;
20
+ message: string;
21
+ } | {
22
+ state: "disconnected";
23
+ } | {
24
+ state: "unavailable";
25
+ message: string;
26
+ };
27
+ export interface SyncOptions {
28
+ agentKid: string;
29
+ hookVersion: string;
30
+ policyBuilds: (policy: Record<string, unknown>) => boolean;
31
+ fetchImpl?: typeof fetch;
32
+ now?: () => Date;
33
+ /** Recompute a project's trust pin after its policy.json changed (the same step `rules apply` takes). */
34
+ afterPolicyWrite?: (dir: string) => void;
35
+ /** Per-request timeout; defaults to five seconds. */
36
+ timeoutMs?: number;
37
+ }
38
+ export declare function syncPolicy(dir: string, options: SyncOptions): Promise<SyncOutcome>;
39
+ /** Called on every tool call: when this computer is connected and has not checked in five minutes, run the check, capped at
40
+ * `budgetMs`. Resolves `null` when no check was due, another one is running, or the cap was reached first (the check then
41
+ * finishes if the process lives long enough, else runs again on a later call). Never throws. `makeOptions` is called only when a
42
+ * check is due, so an ordinary call pays nothing but two file reads. */
43
+ export declare function syncIfDue(dir: string, makeOptions: () => SyncOptions, budgetMs?: number, now?: number): Promise<SyncOutcome | null>;
44
+ export declare function releaseSyncLock(dir: string): void;
45
+ //# sourceMappingURL=policy-sync.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy-sync.d.ts","sourceRoot":"","sources":["../src/policy-sync.ts"],"names":[],"mappings":"AAmBA,OAAO,EAC2F,KAAK,aAAa,EACnH,MAAM,cAAc,CAAC;AAEtB,eAAO,MAAM,gBAAgB,QAAgB,CAAC;AAI9C,+GAA+G;AAC/G,eAAO,MAAM,gBAAgB,OAAQ,CAAC;AAGtC,MAAM,MAAM,WAAW,GACnB;IAAE,KAAK,EAAE,eAAe,CAAA;CAAE,GAC1B;IAAE,KAAK,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GACxC;IAAE,KAAK,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACxC;IAAE,KAAK,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,KAAK,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACrF;IAAE,KAAK,EAAE,cAAc,CAAA;CAAE,GACzB;IAAE,KAAK,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9C,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC;IAC3D,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB,yGAAyG;IACzG,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,qDAAqD;IACrD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CA4FxF;AAED;;;yEAGyE;AACzE,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,WAAW,EAAE,QAAQ,SAAmE,EAAE,GAAG,SAAa,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAyBvM;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAEjD"}
@@ -0,0 +1,171 @@
1
+ // Keep a connected computer's rules in step with its Scopebond Cloud workspace.
2
+ //
3
+ // `syncPolicy` asks the workspace for this computer's rules (`GET /v1/policy`), installs a newer version when there is one, and
4
+ // tells the workspace exactly what it loaded or why it could not (`POST /v1/policy/ack`). A tool call checks two small files;
5
+ // at most every five minutes it also runs the check (`syncIfDue`), in parallel with the delivery of its activity record and
6
+ // capped at about one and a half seconds, so a change made in the workspace applies within a few minutes of the agent's next
7
+ // action. It runs in the hook's own process on purpose: a detached helper process inherits the hook's output pipe on Windows,
8
+ // which makes the coding agent wait for the helper. Any failure or timeout keeps the rules already in force; an interrupted
9
+ // write cannot leave a half-written policy, because every write is a temp file and a rename.
10
+ //
11
+ // Answers from the workspace:
12
+ // 204 the workspace does not set rules for this computer: use (or go back to) its own rules
13
+ // 304 nothing changed since the last check
14
+ // 200 a rules document: checked, installed if newer, confirmed
15
+ // 401 the connection is no longer valid (revoked, expired, removed): go back to this computer's own rules
16
+ import { closeSync, existsSync, openSync, rmSync, statSync, writeSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { connectionPath, loadConnection } from "./cloud.js";
19
+ import { inspectManaged, installManaged, isManaged, readMeta, restoreLocal, writeMeta, } from "./managed.js";
20
+ export const SYNC_INTERVAL_MS = 5 * 60 * 1000;
21
+ const LOCK_FILE = "managed-sync.lock";
22
+ const LOCK_STALE_MS = 2 * 60 * 1000;
23
+ const REQUEST_TIMEOUT_MS = 5_000;
24
+ /** How long a tool call may spend on the five-minute check, at most (`SCOPEBOND_POLICY_SYNC_MS` overrides). */
25
+ export const INLINE_BUDGET_MS = 1_500;
26
+ const UNMANAGED_DIGEST = "0".repeat(64);
27
+ export async function syncPolicy(dir, options) {
28
+ const connection = loadConnection(dir);
29
+ if (!connection)
30
+ return { state: "not_connected" };
31
+ const fetchImpl = options.fetchImpl ?? fetch;
32
+ const now = (options.now ?? (() => new Date()))();
33
+ const installationId = connection.installation_id ?? connection.gateway_id;
34
+ const base = connection.url.replace(/\/+$/, "");
35
+ const auth = { authorization: `Bearer ${connection.credential}` };
36
+ const meta = readMeta(dir);
37
+ const save = (patch) => writeMeta(dir, { ...meta, ...patch, checked_at: now.toISOString() });
38
+ const ack = async (body) => {
39
+ try {
40
+ const res = await fetchImpl(`${base}/v1/policy/ack`, {
41
+ method: "POST", headers: { ...auth, "content-type": "application/json" }, redirect: "error", signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS),
42
+ body: JSON.stringify({ ...body, reason: body.reason ?? null, hook_version: options.hookVersion }),
43
+ });
44
+ return res.ok ? { revision: body.revision, result: body.result, at: now.toISOString() } : meta.last_ack;
45
+ }
46
+ catch {
47
+ return meta.last_ack;
48
+ }
49
+ };
50
+ const backToOwnRules = () => {
51
+ if (!isManaged(dir))
52
+ return false;
53
+ restoreLocal(dir, options.agentKid);
54
+ options.afterPolicyWrite?.(dir);
55
+ return true;
56
+ };
57
+ let res;
58
+ try {
59
+ res = await fetchImpl(`${base}/v1/policy`, {
60
+ headers: { ...auth, ...(isManaged(dir) && meta.etag ? { "if-none-match": meta.etag } : {}) },
61
+ redirect: "error", signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS),
62
+ });
63
+ }
64
+ catch (error) {
65
+ save({ last_error: `could not reach the workspace (${error.name}); the rules in force stay` });
66
+ return { state: "unavailable", message: "could not reach the workspace" };
67
+ }
68
+ if (res.status === 401) {
69
+ backToOwnRules();
70
+ save({ revision: null, rules_digest: null, export_id: null, etag: null, last_error: "the workspace connection is no longer valid; this computer uses its own rules" });
71
+ return { state: "disconnected" };
72
+ }
73
+ if (res.status === 204) {
74
+ const changed = backToOwnRules();
75
+ const last = meta.last_ack;
76
+ const confirmed = changed || !last || last.revision !== 0
77
+ ? await ack({ export_id: `rev-0-${installationId}`, revision: 0, rules_digest: UNMANAGED_DIGEST, result: "loaded" }) : last;
78
+ save({ revision: null, rules_digest: null, export_id: null, etag: null, last_ack: confirmed, last_error: null });
79
+ return { state: "own_rules", changed };
80
+ }
81
+ if (res.status === 304 && isManaged(dir) && meta.revision !== null) {
82
+ const confirmed = meta.last_ack?.revision === meta.revision && meta.last_ack.result === "loaded" ? meta.last_ack
83
+ : await ack({ export_id: meta.export_id, revision: meta.revision, rules_digest: meta.rules_digest, result: "loaded" });
84
+ save({ last_ack: confirmed, last_error: null });
85
+ return { state: "unchanged", revision: meta.revision };
86
+ }
87
+ if (res.status !== 200) {
88
+ save({ last_error: `the workspace answered ${res.status}; the rules in force stay` });
89
+ return { state: "unavailable", message: `the workspace answered ${res.status}` };
90
+ }
91
+ let raw;
92
+ try {
93
+ raw = await res.json();
94
+ }
95
+ catch {
96
+ raw = null;
97
+ }
98
+ const r = (raw && typeof raw === "object" ? raw : {});
99
+ const etag = res.headers.get("etag");
100
+ // The same version already in force: confirm it again if the last confirmation did not land.
101
+ if (isManaged(dir) && r.revision === meta.revision && r.rules_digest === meta.rules_digest && meta.revision !== null) {
102
+ const confirmed = meta.last_ack?.revision === meta.revision ? meta.last_ack
103
+ : await ack({ export_id: meta.export_id, revision: meta.revision, rules_digest: meta.rules_digest, result: "loaded" });
104
+ save({ etag, last_ack: confirmed, last_error: null });
105
+ return { state: "unchanged", revision: meta.revision };
106
+ }
107
+ const inspected = inspectManaged(raw, { installationId, currentRevision: isManaged(dir) ? meta.revision : null });
108
+ const echo = typeof r.export_id === "string" && Number.isInteger(r.revision) && typeof r.rules_digest === "string" && /^[0-9a-f]{64}$/.test(r.rules_digest)
109
+ ? { export_id: r.export_id, revision: r.revision, rules_digest: r.rules_digest } : null;
110
+ if (!inspected.ok) {
111
+ const confirmed = echo ? await ack({ ...echo, result: "rejected", reason: inspected.reason }) : meta.last_ack;
112
+ save({ last_ack: confirmed, last_error: inspected.message });
113
+ return { state: "refused", revision: echo?.revision ?? null, reason: inspected.reason, message: inspected.message };
114
+ }
115
+ const doc = inspected.doc;
116
+ const installed = installManaged(dir, doc, options.agentKid, options.policyBuilds);
117
+ if (!installed.ok) {
118
+ const confirmed = await ack({ export_id: doc.export_id, revision: doc.revision, rules_digest: doc.rules_digest, result: "rejected", reason: installed.reason });
119
+ save({ last_ack: confirmed, last_error: installed.message });
120
+ return { state: "refused", revision: doc.revision, reason: installed.reason, message: installed.message };
121
+ }
122
+ options.afterPolicyWrite?.(dir);
123
+ const confirmed = await ack({ export_id: doc.export_id, revision: doc.revision, rules_digest: doc.rules_digest, result: "loaded" });
124
+ save({ revision: doc.revision, rules_digest: doc.rules_digest, export_id: doc.export_id, etag, last_ack: confirmed, last_error: null });
125
+ return { state: "applied", revision: doc.revision };
126
+ }
127
+ /** Called on every tool call: when this computer is connected and has not checked in five minutes, run the check, capped at
128
+ * `budgetMs`. Resolves `null` when no check was due, another one is running, or the cap was reached first (the check then
129
+ * finishes if the process lives long enough, else runs again on a later call). Never throws. `makeOptions` is called only when a
130
+ * check is due, so an ordinary call pays nothing but two file reads. */
131
+ export async function syncIfDue(dir, makeOptions, budgetMs = Number(process.env.SCOPEBOND_POLICY_SYNC_MS ?? INLINE_BUDGET_MS), now = Date.now()) {
132
+ try {
133
+ if (process.env.SCOPEBOND_POLICY_SYNC === "off")
134
+ return null;
135
+ if (!existsSync(connectionPath(dir)))
136
+ return null;
137
+ const checked = readMeta(dir).checked_at;
138
+ if (checked && now - Date.parse(checked) < SYNC_INTERVAL_MS)
139
+ return null;
140
+ const lock = join(dir, LOCK_FILE);
141
+ if (existsSync(lock)) {
142
+ if (now - statSync(lock).mtimeMs < LOCK_STALE_MS)
143
+ return null;
144
+ rmSync(lock, { force: true });
145
+ }
146
+ const fd = openSync(lock, "wx");
147
+ writeSync(fd, String(process.pid));
148
+ closeSync(fd);
149
+ const budget = Number.isFinite(budgetMs) && budgetMs > 0 ? budgetMs : INLINE_BUDGET_MS;
150
+ const work = Promise.resolve()
151
+ .then(() => syncPolicy(dir, { ...makeOptions(), timeoutMs: budget }))
152
+ .catch(() => ({ state: "unavailable", message: "the check failed" }))
153
+ .finally(() => releaseSyncLock(dir));
154
+ let timer;
155
+ const capped = new Promise((resolve) => { timer = setTimeout(() => resolve(null), budget); });
156
+ const outcome = await Promise.race([work, capped]);
157
+ if (timer)
158
+ clearTimeout(timer);
159
+ return outcome;
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
165
+ export function releaseSyncLock(dir) {
166
+ try {
167
+ rmSync(join(dir, LOCK_FILE), { force: true });
168
+ }
169
+ catch { /* expires on its own */ }
170
+ }
171
+ //# sourceMappingURL=policy-sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy-sync.js","sourceRoot":"","sources":["../src/policy-sync.ts"],"names":[],"mappings":"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","sourcesContent":["// Keep a connected computer's rules in step with its Scopebond Cloud workspace.\n//\n// `syncPolicy` asks the workspace for this computer's rules (`GET /v1/policy`), installs a newer version when there is one, and\n// tells the workspace exactly what it loaded or why it could not (`POST /v1/policy/ack`). A tool call checks two small files;\n// at most every five minutes it also runs the check (`syncIfDue`), in parallel with the delivery of its activity record and\n// capped at about one and a half seconds, so a change made in the workspace applies within a few minutes of the agent's next\n// action. It runs in the hook's own process on purpose: a detached helper process inherits the hook's output pipe on Windows,\n// which makes the coding agent wait for the helper. Any failure or timeout keeps the rules already in force; an interrupted\n// write cannot leave a half-written policy, because every write is a temp file and a rename.\n//\n// Answers from the workspace:\n// 204 the workspace does not set rules for this computer: use (or go back to) its own rules\n// 304 nothing changed since the last check\n// 200 a rules document: checked, installed if newer, confirmed\n// 401 the connection is no longer valid (revoked, expired, removed): go back to this computer's own rules\n\nimport { closeSync, existsSync, openSync, rmSync, statSync, writeSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { connectionPath, loadConnection } from \"./cloud.js\";\nimport {\n inspectManaged, installManaged, isManaged, readMeta, restoreLocal, writeMeta, type ManagedMeta, type RefusalReason,\n} from \"./managed.js\";\n\nexport const SYNC_INTERVAL_MS = 5 * 60 * 1000;\nconst LOCK_FILE = \"managed-sync.lock\";\nconst LOCK_STALE_MS = 2 * 60 * 1000;\nconst REQUEST_TIMEOUT_MS = 5_000;\n/** How long a tool call may spend on the five-minute check, at most (`SCOPEBOND_POLICY_SYNC_MS` overrides). */\nexport const INLINE_BUDGET_MS = 1_500;\nconst UNMANAGED_DIGEST = \"0\".repeat(64);\n\nexport type SyncOutcome =\n | { state: \"not_connected\" }\n | { state: \"own_rules\"; changed: boolean }\n | { state: \"unchanged\"; revision: number }\n | { state: \"applied\"; revision: number }\n | { state: \"refused\"; revision: number | null; reason: RefusalReason; message: string }\n | { state: \"disconnected\" }\n | { state: \"unavailable\"; message: string };\n\nexport interface SyncOptions {\n agentKid: string;\n hookVersion: string;\n policyBuilds: (policy: Record<string, unknown>) => boolean;\n fetchImpl?: typeof fetch;\n now?: () => Date;\n /** Recompute a project's trust pin after its policy.json changed (the same step `rules apply` takes). */\n afterPolicyWrite?: (dir: string) => void;\n /** Per-request timeout; defaults to five seconds. */\n timeoutMs?: number;\n}\n\nexport async function syncPolicy(dir: string, options: SyncOptions): Promise<SyncOutcome> {\n const connection = loadConnection(dir);\n if (!connection) return { state: \"not_connected\" };\n const fetchImpl = options.fetchImpl ?? fetch;\n const now = (options.now ?? (() => new Date()))();\n const installationId = (connection as { installation_id?: string }).installation_id ?? connection.gateway_id;\n const base = connection.url.replace(/\\/+$/, \"\");\n const auth = { authorization: `Bearer ${connection.credential}` };\n const meta = readMeta(dir);\n const save = (patch: Partial<ManagedMeta>) => writeMeta(dir, { ...meta, ...patch, checked_at: now.toISOString() });\n\n const ack = async (body: { export_id: string; revision: number; rules_digest: string; result: \"loaded\" | \"rejected\"; reason?: RefusalReason }): Promise<ManagedMeta[\"last_ack\"]> => {\n try {\n const res = await fetchImpl(`${base}/v1/policy/ack`, {\n method: \"POST\", headers: { ...auth, \"content-type\": \"application/json\" }, redirect: \"error\", signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS),\n body: JSON.stringify({ ...body, reason: body.reason ?? null, hook_version: options.hookVersion }),\n });\n return res.ok ? { revision: body.revision, result: body.result, at: now.toISOString() } : meta.last_ack;\n } catch { return meta.last_ack; }\n };\n const backToOwnRules = (): boolean => {\n if (!isManaged(dir)) return false;\n restoreLocal(dir, options.agentKid);\n options.afterPolicyWrite?.(dir);\n return true;\n };\n\n let res: Response;\n try {\n res = await fetchImpl(`${base}/v1/policy`, {\n headers: { ...auth, ...(isManaged(dir) && meta.etag ? { \"if-none-match\": meta.etag } : {}) },\n redirect: \"error\", signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS),\n });\n } catch (error) {\n save({ last_error: `could not reach the workspace (${(error as Error).name}); the rules in force stay` });\n return { state: \"unavailable\", message: \"could not reach the workspace\" };\n }\n\n if (res.status === 401) {\n backToOwnRules();\n save({ revision: null, rules_digest: null, export_id: null, etag: null, last_error: \"the workspace connection is no longer valid; this computer uses its own rules\" });\n return { state: \"disconnected\" };\n }\n if (res.status === 204) {\n const changed = backToOwnRules();\n const last = meta.last_ack;\n const confirmed = changed || !last || last.revision !== 0\n ? await ack({ export_id: `rev-0-${installationId}`, revision: 0, rules_digest: UNMANAGED_DIGEST, result: \"loaded\" }) : last;\n save({ revision: null, rules_digest: null, export_id: null, etag: null, last_ack: confirmed, last_error: null });\n return { state: \"own_rules\", changed };\n }\n if (res.status === 304 && isManaged(dir) && meta.revision !== null) {\n const confirmed = meta.last_ack?.revision === meta.revision && meta.last_ack.result === \"loaded\" ? meta.last_ack\n : await ack({ export_id: meta.export_id!, revision: meta.revision, rules_digest: meta.rules_digest!, result: \"loaded\" });\n save({ last_ack: confirmed, last_error: null });\n return { state: \"unchanged\", revision: meta.revision };\n }\n if (res.status !== 200) {\n save({ last_error: `the workspace answered ${res.status}; the rules in force stay` });\n return { state: \"unavailable\", message: `the workspace answered ${res.status}` };\n }\n\n let raw: unknown;\n try { raw = await res.json(); } catch { raw = null; }\n const r = (raw && typeof raw === \"object\" ? raw : {}) as Record<string, unknown>;\n const etag = res.headers.get(\"etag\");\n // The same version already in force: confirm it again if the last confirmation did not land.\n if (isManaged(dir) && r.revision === meta.revision && r.rules_digest === meta.rules_digest && meta.revision !== null) {\n const confirmed = meta.last_ack?.revision === meta.revision ? meta.last_ack\n : await ack({ export_id: meta.export_id!, revision: meta.revision, rules_digest: meta.rules_digest!, result: \"loaded\" });\n save({ etag, last_ack: confirmed, last_error: null });\n return { state: \"unchanged\", revision: meta.revision };\n }\n const inspected = inspectManaged(raw, { installationId, currentRevision: isManaged(dir) ? meta.revision : null });\n const echo = typeof r.export_id === \"string\" && Number.isInteger(r.revision) && typeof r.rules_digest === \"string\" && /^[0-9a-f]{64}$/.test(r.rules_digest)\n ? { export_id: r.export_id, revision: r.revision as number, rules_digest: r.rules_digest } : null;\n if (!inspected.ok) {\n const confirmed = echo ? await ack({ ...echo, result: \"rejected\", reason: inspected.reason }) : meta.last_ack;\n save({ last_ack: confirmed, last_error: inspected.message });\n return { state: \"refused\", revision: echo?.revision ?? null, reason: inspected.reason, message: inspected.message };\n }\n const doc = inspected.doc;\n const installed = installManaged(dir, doc, options.agentKid, options.policyBuilds);\n if (!installed.ok) {\n const confirmed = await ack({ export_id: doc.export_id, revision: doc.revision, rules_digest: doc.rules_digest, result: \"rejected\", reason: installed.reason });\n save({ last_ack: confirmed, last_error: installed.message });\n return { state: \"refused\", revision: doc.revision, reason: installed.reason, message: installed.message };\n }\n options.afterPolicyWrite?.(dir);\n const confirmed = await ack({ export_id: doc.export_id, revision: doc.revision, rules_digest: doc.rules_digest, result: \"loaded\" });\n save({ revision: doc.revision, rules_digest: doc.rules_digest, export_id: doc.export_id, etag, last_ack: confirmed, last_error: null });\n return { state: \"applied\", revision: doc.revision };\n}\n\n/** Called on every tool call: when this computer is connected and has not checked in five minutes, run the check, capped at\n * `budgetMs`. Resolves `null` when no check was due, another one is running, or the cap was reached first (the check then\n * finishes if the process lives long enough, else runs again on a later call). Never throws. `makeOptions` is called only when a\n * check is due, so an ordinary call pays nothing but two file reads. */\nexport async function syncIfDue(dir: string, makeOptions: () => SyncOptions, budgetMs = Number(process.env.SCOPEBOND_POLICY_SYNC_MS ?? INLINE_BUDGET_MS), now = Date.now()): Promise<SyncOutcome | null> {\n try {\n if (process.env.SCOPEBOND_POLICY_SYNC === \"off\") return null;\n if (!existsSync(connectionPath(dir))) return null;\n const checked = readMeta(dir).checked_at;\n if (checked && now - Date.parse(checked) < SYNC_INTERVAL_MS) return null;\n const lock = join(dir, LOCK_FILE);\n if (existsSync(lock)) {\n if (now - statSync(lock).mtimeMs < LOCK_STALE_MS) return null;\n rmSync(lock, { force: true });\n }\n const fd = openSync(lock, \"wx\");\n writeSync(fd, String(process.pid));\n closeSync(fd);\n const budget = Number.isFinite(budgetMs) && budgetMs > 0 ? budgetMs : INLINE_BUDGET_MS;\n const work = Promise.resolve()\n .then(() => syncPolicy(dir, { ...makeOptions(), timeoutMs: budget }))\n .catch((): SyncOutcome => ({ state: \"unavailable\", message: \"the check failed\" }))\n .finally(() => releaseSyncLock(dir));\n let timer: ReturnType<typeof setTimeout> | undefined;\n const capped = new Promise<null>((resolve) => { timer = setTimeout(() => resolve(null), budget); });\n const outcome = await Promise.race([work, capped]);\n if (timer) clearTimeout(timer);\n return outcome;\n } catch { return null; }\n}\n\nexport function releaseSyncLock(dir: string): void {\n try { rmSync(join(dir, LOCK_FILE), { force: true }); } catch { /* expires on its own */ }\n}\n"]}
@@ -0,0 +1,30 @@
1
+ import { type CapabilityCell, type ProofRecord } from "./capabilities.js";
2
+ import type { HookConnection } from "./cloud.js";
3
+ export declare const PROOF_FILE = "capability-proof.json";
4
+ /** Options for a fixture run. */
5
+ export interface ProofRunOptions {
6
+ /** A real hook config dir whose machine and countersigning keys the fixtures should be
7
+ * signed with (copied into the temp home, never modified), so the workspace can accept
8
+ * the fixture receipts and resolve a proof's digests against them. Without it the
9
+ * fixtures use throwaway keys and produce no digests a workspace could resolve. */
10
+ identityDir?: string;
11
+ /** Receives every fixture receipt produced, once each, for delivery. */
12
+ collect?: unknown[];
13
+ }
14
+ /** Run every supported cell's fixtures once per adapter/action/phase, and record the
15
+ * result under each host variant's key. A fixture exercises the adapter, so it cannot
16
+ * tell Codex desktop from Codex CLI: both keys get the same fixture proof, and neither
17
+ * becomes verified (see `cellState`). */
18
+ export declare function runProofFixtures(adapterVersion: string, options?: ProofRunOptions): Promise<Record<string, ProofRecord>>;
19
+ export declare function loadProofs(configDir: string): Record<string, ProofRecord>;
20
+ export declare function saveProofs(configDir: string, proofs: Record<string, ProofRecord>): string;
21
+ /** Whether a cell's fixture proof passed, for a summary line. */
22
+ export declare const proofPassed: (proof: ProofRecord, cell: CapabilityCell) => boolean;
23
+ /** Deliver fixture receipts to the workspace's receipt route and report whether every one was
24
+ * accepted. A proof names these receipts by digest, so it is only worth sending after they
25
+ * landed; anything short of full delivery leaves the proof unsent, never half-claimed. */
26
+ export declare function deliverProofReceipts(connection: HookConnection, receipts: unknown[], options?: {
27
+ fetch?: typeof fetch;
28
+ timeoutMs?: number;
29
+ }): Promise<boolean>;
30
+ //# sourceMappingURL=proof.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proof.d.ts","sourceRoot":"","sources":["../src/proof.ts"],"names":[],"mappings":"AA4BA,OAAO,EAA4E,KAAK,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAKpJ,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD,eAAO,MAAM,UAAU,0BAA0B,CAAC;AAKlD,iCAAiC;AACjC,MAAM,WAAW,eAAe;IAC9B;;;wFAGoF;IACpF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC;CACrB;AAyED;;;0CAG0C;AAC1C,wBAAsB,gBAAgB,CAAC,cAAc,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAkBlI;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAOzE;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,MAAM,CAIzF;AAED,iEAAiE;AACjE,eAAO,MAAM,WAAW,GAAI,OAAO,WAAW,EAAE,MAAM,cAAc,KAAG,OAC6E,CAAC;AAErJ;;2FAE2F;AAC3F,wBAAsB,oBAAoB,CAAC,UAAU,EAAE,cAAc,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAkBxK"}