@axtary/cli 0.0.1 → 0.1.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/dist/index.js CHANGED
@@ -2,18 +2,43 @@ import { generateKeyPairSync } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import { readFile, readdir, stat, writeFile } from "node:fs/promises";
4
4
  import { extname, resolve } from "node:path";
5
- import { AxtaryDecisionSchema, demoAction, parseNormalizedAction, } from "@axtary/actionpass";
5
+ import { createInterface } from "node:readline";
6
+ import { AxtaryDecisionSchema, createApprovalArtifact, demoAction, hashPayload, parseNormalizedAction, } from "@axtary/actionpass";
6
7
  import { createFakeAdapterState, createFakeHandlers, createAwsRestHandlers, createGcpRestHandlers, createGitHubRestHandlers, createLinearGraphqlHandlers, createLocalDocsHandlers, createSlackWebHandlers, smokeAwsRestCredentials, smokeGcpRestCredentials, smokeGitHubRestCredentials, smokeLinearGraphqlCredentials, smokeLocalDocsRoots, smokeSlackWebCredentials, } from "@axtary/adapters";
7
8
  import { loadAxtaryConfig, CachedConfigLoader } from "@axtary/config";
8
9
  import { exportLedgerRecords, formatLedgerExport, LedgerExportFormatSchema, LocalJsonlLedger, syncLedgerExport, } from "@axtary/ledger";
9
- import { MCP_TOOL_CALL, createMcpAction, createMcpFixtureToolHandler, createMcpToolDefinition, } from "@axtary/mcp";
10
- import { evaluatePolicy } from "@axtary/policy";
10
+ import { MCP_TOOL_CALL, McpStdioClient, createMcpAction, createMcpFixtureToolHandler, createMcpToolDefinition, createMcpToolRouter, createMcpWrapperServer, createMcpWrapperTool, } from "@axtary/mcp";
11
+ import { evaluatePolicy, explainPolicyDecision } from "@axtary/policy";
11
12
  import { createProxyRuntime, } from "@axtary/proxy";
13
+ import { runClaudeCodeHook } from "./claude-code-hook.js";
14
+ export { CLAUDE_CODE_HOOK_SCHEMA_VERSION, normalizeClaudeCodeToolCall, parseClaudeCodeHookPayload, runClaudeCodeHook, } from "./claude-code-hook.js";
12
15
  export const DEMO_SCHEMA_VERSION = "axtary.demo.v0";
13
16
  export const POLICY_TEST_SCHEMA_VERSION = "axtary.policy_test.v0";
14
17
  export const LOCAL_PROXY_SCHEMA_VERSION = "axtary.local_proxy.v0";
15
18
  export const LEDGER_EXPORT_RUN_SCHEMA_VERSION = "axtary.ledger_export_run.v0";
16
19
  export const LEDGER_SYNC_RUN_SCHEMA_VERSION = "axtary.ledger_sync_run.v0";
20
+ export const CONNECTOR_DOCTOR_SCHEMA_VERSION = "axtary.connector_doctor.v0";
21
+ export const REAL_WORKFLOW_RUN_SCHEMA_VERSION = "axtary.real_workflow_run.v0";
22
+ export function authorizeDecisionHints(decision, policy) {
23
+ const hints = [];
24
+ for (const reason of decision.reasons) {
25
+ if (reason === "slack_channel_not_allowed") {
26
+ hints.push(`Allowed Slack channels: ${policy.slack.messages.allowedChannels.join(", ") || "none configured"}.`);
27
+ }
28
+ if ((reason === "content_read_denied_path" ||
29
+ reason === "payload_touches_denied_path") &&
30
+ decision.constraints.blockedPaths.length > 0) {
31
+ hints.push(`Blocked path prefixes: ${decision.constraints.blockedPaths.join(", ")}.`);
32
+ }
33
+ if (reason === "payload_touches_step_up_path") {
34
+ hints.push("A human must approve the exact payload before this executes.");
35
+ }
36
+ if (reason.startsWith("unsupported_tool:")) {
37
+ hints.push("This tool is not governed by the active policy and fails closed.");
38
+ }
39
+ }
40
+ return hints;
41
+ }
17
42
  const MCP_DEMO_TOOL_DEFINITION = createMcpToolDefinition({
18
43
  serverIdentity: "mcp:axtary-demo-fixtures",
19
44
  serverVersion: "1.0.0",
@@ -250,6 +275,24 @@ export async function startProxyServer(input = {}) {
250
275
  writeJson(response, statusCode, result);
251
276
  return;
252
277
  }
278
+ if (request.method === "POST" && request.url === "/authorize") {
279
+ const body = await readJsonBody(request);
280
+ const result = await runtime.authorize(body);
281
+ if (result.status === "malformed") {
282
+ writeJson(response, 400, result);
283
+ return;
284
+ }
285
+ const policy = (await configLoader.getConfig()).config.policy;
286
+ writeJson(response, 200, {
287
+ ...result,
288
+ actionPass: result.actionPass
289
+ ? { token: result.actionPass.token, claims: result.actionPass.claims }
290
+ : null,
291
+ explanation: explainPolicyDecision(result.decision),
292
+ hints: authorizeDecisionHints(result.decision, policy),
293
+ });
294
+ return;
295
+ }
253
296
  writeJson(response, 404, {
254
297
  schemaVersion: LOCAL_PROXY_SCHEMA_VERSION,
255
298
  error: "not_found",
@@ -276,6 +319,245 @@ export async function startProxyServer(input = {}) {
276
319
  close: () => close(server),
277
320
  };
278
321
  }
322
+ export const INIT_SCHEMA_VERSION = "axtary.init.v0";
323
+ export const INIT_CONFIG_TEMPLATE = `schemaVersion: axtary.config.v0
324
+ issuer: https://local.axtary.dev
325
+ tenant: org:local
326
+ ledger:
327
+ path: .axtary/actions.jsonl
328
+ runtime:
329
+ allowUnsignedExecution: false
330
+ handlerTimeoutMs: 10000
331
+ adapters:
332
+ # Start in fake mode: deterministic local adapters, no credentials needed.
333
+ # Switch a provider to its real mode (rest/web/graphql) once its env token is set.
334
+ github:
335
+ mode: fake
336
+ tokenEnv: GITHUB_TOKEN
337
+ apiBaseUrl: https://api.github.com
338
+ userAgent: axtary-local-proxy
339
+ slack:
340
+ mode: fake
341
+ tokenEnv: SLACK_BOT_TOKEN
342
+ apiBaseUrl: https://slack.com/api
343
+ linear:
344
+ mode: fake
345
+ tokenEnv: LINEAR_API_KEY
346
+ apiUrl: https://api.linear.app/graphql
347
+ policy:
348
+ version: "${new Date().toISOString().slice(0, 10)}"
349
+ github:
350
+ pullRequests:
351
+ requiredBaseBranch: main
352
+ maxFilesChanged: 12
353
+ denyPathPrefixes:
354
+ - .env
355
+ - secrets/
356
+ stepUpPathPrefixes:
357
+ - infra/prod/
358
+ - billing/
359
+ - auth/
360
+ requiresTests: true
361
+ contents:
362
+ denyReadPathPrefixes:
363
+ - .env
364
+ - secrets/
365
+ - infra/prod/
366
+ slack:
367
+ messages:
368
+ allowedChannels:
369
+ - "#axtary-dev"
370
+ stepUpExternalRecipients: true
371
+ demo:
372
+ scenario: coding-agent
373
+ `;
374
+ export async function runInit(input = {}) {
375
+ const cwd = resolve(input.cwd ?? process.cwd());
376
+ const configPath = resolve(cwd, input.configPath ?? "axtary.yml");
377
+ const exists = await stat(configPath).then(() => true, () => false);
378
+ if (exists && input.force !== true) {
379
+ return {
380
+ schemaVersion: INIT_SCHEMA_VERSION,
381
+ status: "exists",
382
+ configPath,
383
+ };
384
+ }
385
+ await writeFile(configPath, INIT_CONFIG_TEMPLATE, "utf8");
386
+ return {
387
+ schemaVersion: INIT_SCHEMA_VERSION,
388
+ status: "created",
389
+ configPath,
390
+ };
391
+ }
392
+ export function formatInitResult(result) {
393
+ if (result.status === "exists") {
394
+ return [
395
+ `axtary.yml already exists at ${result.configPath}.`,
396
+ "Re-run with --force to overwrite it.",
397
+ "",
398
+ ].join("\n");
399
+ }
400
+ return [
401
+ `Created ${result.configPath}.`,
402
+ "",
403
+ "Next steps (the ten-minute rail, see docs/quickstart.md):",
404
+ " 1. axtary doctor connectors --config axtary.yml # readiness checklist; fake mode needs no credentials",
405
+ " 2. axtary proxy --config axtary.yml # start the local enforcement point",
406
+ " 3. axtary demo --config axtary.yml # watch safe + unsafe actions hit policy and the ledger",
407
+ " 4. Gate a live Claude Code session: docs/claude-code-hook.md (PreToolUse hook)",
408
+ " or any MCP agent: docs/mcp-wrapper.md (axtary mcp serve)",
409
+ "",
410
+ "First blocked action without an agent:",
411
+ " with the proxy running, ask it to read .env.production through the hook:",
412
+ " echo '{\"tool_name\":\"Read\",\"tool_input\":{\"file_path\":\".env.production\"}}' | axtary hook claude-code",
413
+ "",
414
+ ].join("\n");
415
+ }
416
+ export const MCP_SERVE_SCHEMA_VERSION = "axtary.mcp_serve.v0";
417
+ export async function startMcpServe(input = {}) {
418
+ const loadedConfig = await loadAxtaryConfig({
419
+ filePath: input.configPath,
420
+ cwd: input.cwd,
421
+ });
422
+ const cwd = resolve(input.cwd ?? process.cwd());
423
+ const ledgerPath = input.ledgerPath
424
+ ? resolve(cwd, input.ledgerPath)
425
+ : loadedConfig.ledgerPath;
426
+ const ledger = new LocalJsonlLedger(ledgerPath);
427
+ const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
428
+ const log = input.log ?? ((text) => process.stderr.write(text));
429
+ let upstream = null;
430
+ let tools;
431
+ if (input.wrap) {
432
+ const [command, ...commandArgs] = input.wrap.split(/\s+/).filter(Boolean);
433
+ if (!command) {
434
+ throw new Error("mcp_serve_wrap_command_required");
435
+ }
436
+ upstream = await McpStdioClient.start({ command, args: commandArgs });
437
+ const serverInfo = upstream.serverInfo;
438
+ const listings = await upstream.listTools();
439
+ const client = upstream;
440
+ tools = listings.map((listing) => createMcpWrapperTool({
441
+ definition: {
442
+ serverIdentity: `mcp:${serverInfo.name}`,
443
+ serverVersion: serverInfo.version,
444
+ schemaVersion: serverInfo.version,
445
+ name: listing.name,
446
+ description: listing.description,
447
+ inputSchema: listing.inputSchema,
448
+ },
449
+ invoke: ({ call }) => client.callTool(call.toolName, call.arguments),
450
+ }));
451
+ }
452
+ else {
453
+ tools = [
454
+ createMcpWrapperTool({
455
+ definition: MCP_DEMO_TOOL_DEFINITION,
456
+ invoke: ({ call }) => {
457
+ const fixtureKey = typeof call.arguments.fixtureKey === "string"
458
+ ? call.arguments.fixtureKey
459
+ : "";
460
+ const fixture = MCP_DEMO_FIXTURES[fixtureKey];
461
+ if (fixture === undefined) {
462
+ throw new Error(`mcp_fixture_not_found:${fixtureKey}`);
463
+ }
464
+ return fixture;
465
+ },
466
+ }),
467
+ ];
468
+ }
469
+ if (tools.length === 0) {
470
+ upstream?.close();
471
+ throw new Error("mcp_serve_no_tools_discovered");
472
+ }
473
+ const policy = {
474
+ ...loadedConfig.config.policy,
475
+ mcp: {
476
+ ...loadedConfig.config.policy.mcp,
477
+ enabled: true,
478
+ allowedTools: [
479
+ ...(loadedConfig.config.policy.mcp.allowedTools ?? []),
480
+ ...tools.map((tool) => ({
481
+ serverIdentity: tool.definition.serverIdentity,
482
+ toolName: tool.definition.name,
483
+ definitionHash: tool.definition.definitionHash,
484
+ })),
485
+ ],
486
+ },
487
+ };
488
+ const runtime = createProxyRuntime({
489
+ issuer: loadedConfig.config.issuer,
490
+ tenant: loadedConfig.config.tenant,
491
+ ledger,
492
+ policy,
493
+ signingKey: privateKey,
494
+ keyId: "local-mcp-serve-key",
495
+ allowUnsignedExecution: loadedConfig.config.runtime.allowUnsignedExecution,
496
+ handlerTimeoutMs: loadedConfig.config.runtime.handlerTimeoutMs,
497
+ handlers: {
498
+ [MCP_TOOL_CALL]: createMcpToolRouter(tools),
499
+ },
500
+ });
501
+ const server = createMcpWrapperServer({
502
+ tools,
503
+ runtime,
504
+ actor: {
505
+ agentId: "agent:mcp-client",
506
+ humanOwner: input.owner ?? "user:local-developer",
507
+ runtime: "mcp",
508
+ },
509
+ });
510
+ const inputStream = input.input ?? process.stdin;
511
+ const outputStream = input.output ?? process.stdout;
512
+ const readline = createInterface({ input: inputStream });
513
+ let pendingWrites = Promise.resolve();
514
+ readline.on("line", (line) => {
515
+ const trimmed = line.trim();
516
+ if (trimmed.length === 0) {
517
+ return;
518
+ }
519
+ pendingWrites = pendingWrites.then(async () => {
520
+ let response;
521
+ try {
522
+ response = await server.handleMessage(JSON.parse(trimmed));
523
+ }
524
+ catch {
525
+ response = {
526
+ jsonrpc: "2.0",
527
+ id: null,
528
+ error: { code: -32700, message: "parse_error" },
529
+ };
530
+ }
531
+ if (response !== null) {
532
+ outputStream.write(`${JSON.stringify(response)}\n`);
533
+ }
534
+ });
535
+ });
536
+ const done = new Promise((resolveDone) => {
537
+ readline.once("close", () => {
538
+ void pendingWrites.finally(resolveDone);
539
+ });
540
+ });
541
+ for (const tool of tools) {
542
+ log(`wrapped ${tool.definition.serverIdentity}/${tool.definition.name} ${tool.definition.definitionHash}\n`);
543
+ }
544
+ log(`ledger: ${ledgerPath}\n`);
545
+ return {
546
+ schemaVersion: MCP_SERVE_SCHEMA_VERSION,
547
+ ledgerPath,
548
+ tools: tools.map((tool) => ({
549
+ name: tool.definition.name,
550
+ serverIdentity: tool.definition.serverIdentity,
551
+ definitionHash: tool.definition.definitionHash,
552
+ })),
553
+ done,
554
+ close: async () => {
555
+ readline.close();
556
+ upstream?.close();
557
+ await pendingWrites;
558
+ },
559
+ };
560
+ }
279
561
  export async function runDemo(input = {}) {
280
562
  const loadedConfig = await loadAxtaryConfig({
281
563
  filePath: input.configPath,
@@ -533,6 +815,157 @@ export async function runSmokeTest(input = {}, io = {}) {
533
815
  }
534
816
  return failed ? 1 : 0;
535
817
  }
818
+ export async function runConnectorDoctor(input = {}) {
819
+ const env = input.env ?? process.env;
820
+ const fetchImpl = input.fetch ?? fetch;
821
+ const loadedConfig = await loadAxtaryConfig({
822
+ filePath: input.configPath,
823
+ cwd: input.cwd,
824
+ });
825
+ const providers = [];
826
+ const githubConfig = loadedConfig.config.adapters.github;
827
+ providers.push(await doctorProvider({
828
+ provider: "github",
829
+ mode: githubConfig.mode,
830
+ activeMode: "rest",
831
+ capability: githubConfig.mode === "rest" ? "real-write" : "fake",
832
+ requiredEnv: githubConfig.mode === "rest" ? [githubConfig.tokenEnv] : [],
833
+ requiredScopes: githubConfig.mode === "rest"
834
+ ? ["pull_requests:write", "contents:write", "metadata:read"]
835
+ : [],
836
+ smokeCheck: githubConfig.mode === "rest" ? "GET /user" : null,
837
+ env,
838
+ runSmoke: githubConfig.mode === "rest"
839
+ ? () => smokeGitHubRestCredentials({
840
+ token: env[githubConfig.tokenEnv] ?? "",
841
+ apiBaseUrl: githubConfig.apiBaseUrl,
842
+ userAgent: githubConfig.userAgent,
843
+ fetch: fetchImpl,
844
+ maxRetries: 0,
845
+ })
846
+ : undefined,
847
+ }));
848
+ const slackConfig = loadedConfig.config.adapters.slack;
849
+ providers.push(await doctorProvider({
850
+ provider: "slack",
851
+ mode: slackConfig.mode,
852
+ activeMode: "web",
853
+ capability: slackConfig.mode === "web" ? "real-write" : "fake",
854
+ requiredEnv: slackConfig.mode === "web" ? [slackConfig.tokenEnv] : [],
855
+ requiredScopes: slackConfig.mode === "web" ? ["chat:write"] : [],
856
+ smokeCheck: slackConfig.mode === "web" ? "auth.test" : null,
857
+ env,
858
+ runSmoke: slackConfig.mode === "web"
859
+ ? () => smokeSlackWebCredentials({
860
+ token: env[slackConfig.tokenEnv] ?? "",
861
+ apiBaseUrl: slackConfig.apiBaseUrl,
862
+ fetch: fetchImpl,
863
+ maxRetries: 0,
864
+ })
865
+ : undefined,
866
+ }));
867
+ const linearConfig = loadedConfig.config.adapters.linear;
868
+ providers.push(await doctorProvider({
869
+ provider: "linear",
870
+ mode: linearConfig.mode,
871
+ activeMode: "graphql",
872
+ capability: linearConfig.mode === "graphql" ? "real-write" : "fake",
873
+ requiredEnv: linearConfig.mode === "graphql" ? [linearConfig.tokenEnv] : [],
874
+ requiredScopes: linearConfig.mode === "graphql" ? ["issues:read", "issues:write"] : [],
875
+ smokeCheck: linearConfig.mode === "graphql" ? "viewer" : null,
876
+ env,
877
+ runSmoke: linearConfig.mode === "graphql"
878
+ ? () => smokeLinearGraphqlCredentials({
879
+ token: env[linearConfig.tokenEnv] ?? "",
880
+ apiUrl: linearConfig.apiUrl,
881
+ fetch: fetchImpl,
882
+ maxRetries: 0,
883
+ })
884
+ : undefined,
885
+ }));
886
+ const awsConfig = loadedConfig.config.adapters.aws;
887
+ providers.push(await doctorProvider({
888
+ provider: "aws",
889
+ mode: awsConfig.mode,
890
+ activeMode: "rest",
891
+ capability: awsConfig.mode === "rest" ? "read-only" : "off",
892
+ requiredEnv: awsConfig.mode === "rest"
893
+ ? [awsConfig.accessKeyIdEnv, awsConfig.secretAccessKeyEnv]
894
+ : [],
895
+ requiredScopes: awsConfig.mode === "rest" ? ["sts:GetCallerIdentity"] : [],
896
+ smokeCheck: awsConfig.mode === "rest" ? "STS GetCallerIdentity" : null,
897
+ env,
898
+ runSmoke: awsConfig.mode === "rest"
899
+ ? () => smokeAwsRestCredentials({
900
+ credentials: {
901
+ accessKeyId: env[awsConfig.accessKeyIdEnv] ?? "",
902
+ secretAccessKey: env[awsConfig.secretAccessKeyEnv] ?? "",
903
+ sessionToken: env[awsConfig.sessionTokenEnv],
904
+ },
905
+ region: awsConfig.region,
906
+ stsUrl: awsConfig.stsUrl,
907
+ fetch: fetchImpl,
908
+ maxRetries: 0,
909
+ })
910
+ : undefined,
911
+ }));
912
+ const gcpConfig = loadedConfig.config.adapters.gcp;
913
+ providers.push(await doctorProvider({
914
+ provider: "gcp",
915
+ mode: gcpConfig.mode,
916
+ activeMode: "rest",
917
+ capability: gcpConfig.mode === "rest" ? "read-only" : "off",
918
+ requiredEnv: gcpConfig.mode === "rest" ? [gcpConfig.accessTokenEnv] : [],
919
+ requiredScopes: gcpConfig.mode === "rest"
920
+ ? ["cloudresourcemanager.projects.get", "storage.objects.list"]
921
+ : [],
922
+ smokeCheck: gcpConfig.mode === "rest"
923
+ ? "Cloud Resource Manager project get/list"
924
+ : null,
925
+ env,
926
+ runSmoke: gcpConfig.mode === "rest"
927
+ ? () => smokeGcpRestCredentials({
928
+ accessToken: env[gcpConfig.accessTokenEnv] ?? "",
929
+ projectId: gcpConfig.projectId,
930
+ cloudResourceManagerApiBaseUrl: gcpConfig.cloudResourceManagerApiBaseUrl,
931
+ storageApiBaseUrl: gcpConfig.storageApiBaseUrl,
932
+ fetch: fetchImpl,
933
+ maxRetries: 0,
934
+ })
935
+ : undefined,
936
+ }));
937
+ const docsConfig = loadedConfig.config.adapters.docs;
938
+ providers.push(await doctorProvider({
939
+ provider: "docs",
940
+ mode: docsConfig.mode,
941
+ activeMode: "local",
942
+ capability: docsConfig.mode === "local" ? "local-read" : "off",
943
+ requiredEnv: [],
944
+ requiredScopes: docsConfig.mode === "local" ? ["configured_roots:read"] : [],
945
+ smokeCheck: docsConfig.mode === "local" ? "configured roots exist" : null,
946
+ env,
947
+ runSmoke: docsConfig.mode === "local"
948
+ ? () => smokeLocalDocsRoots({
949
+ workspace: docsConfig.workspace,
950
+ roots: resolveDocsRoots(loadedConfig),
951
+ maxSearchResults: docsConfig.maxSearchResults,
952
+ maxReadBytes: docsConfig.maxReadBytes,
953
+ allowedExtensions: docsConfig.allowedExtensions,
954
+ })
955
+ : undefined,
956
+ }));
957
+ return {
958
+ schemaVersion: CONNECTOR_DOCTOR_SCHEMA_VERSION,
959
+ config: {
960
+ filePath: loadedConfig.filePath,
961
+ tenant: loadedConfig.config.tenant,
962
+ },
963
+ status: providers.some((provider) => provider.status === "missing" || provider.status === "failed")
964
+ ? "needs_setup"
965
+ : "ready",
966
+ providers,
967
+ };
968
+ }
536
969
  export async function runTestPolicy(input) {
537
970
  const loadedConfig = await loadAxtaryConfig({
538
971
  filePath: input.configPath,
@@ -643,6 +1076,105 @@ export async function runLedgerSync(input) {
643
1076
  sync,
644
1077
  };
645
1078
  }
1079
+ export async function runRealWorkflow(input = {}) {
1080
+ const loadedConfig = await loadAxtaryConfig({
1081
+ filePath: input.configPath,
1082
+ cwd: input.cwd,
1083
+ });
1084
+ assertRealWorkflowModes(loadedConfig);
1085
+ const env = input.env ?? process.env;
1086
+ const fetchImpl = input.fetch ?? fetch;
1087
+ const ledgerPath = resolveConfiguredLedgerPath(input, loadedConfig);
1088
+ const ledger = new LocalJsonlLedger(ledgerPath);
1089
+ const adapterState = createFakeAdapterState();
1090
+ const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
1091
+ const { handlers } = createProxyHandlers(loadedConfig, adapterState, {
1092
+ issuer: loadedConfig.config.issuer,
1093
+ verificationKey: publicKey,
1094
+ }, {
1095
+ env,
1096
+ fetch: fetchImpl,
1097
+ });
1098
+ const approvedBy = input.approvedBy ?? "user:local-reviewer";
1099
+ const workflowInputs = workflowInputsFrom(input, loadedConfig);
1100
+ const actions = realWorkflowActions(workflowInputs);
1101
+ const tamperSetup = input.tamper === true ? prepareTamperedSlackStep(actions, approvedBy) : null;
1102
+ const runtime = createProxyRuntime({
1103
+ issuer: loadedConfig.config.issuer,
1104
+ tenant: loadedConfig.config.tenant,
1105
+ ledger,
1106
+ policy: loadedConfig.config.policy,
1107
+ signingKey: privateKey,
1108
+ keyId: "real-workflow-local-key",
1109
+ allowUnsignedExecution: loadedConfig.config.runtime.allowUnsignedExecution,
1110
+ handlerTimeoutMs: loadedConfig.config.runtime.handlerTimeoutMs,
1111
+ handlers,
1112
+ approvalResolver: input.approveStepUp || tamperSetup
1113
+ ? ({ action }) => {
1114
+ if (tamperSetup && action.capability.tool === tamperSetup.tool) {
1115
+ return tamperSetup.staleArtifact;
1116
+ }
1117
+ if (!input.approveStepUp) {
1118
+ return null;
1119
+ }
1120
+ return createApprovalArtifact({
1121
+ action,
1122
+ mode: "human",
1123
+ approvedBy,
1124
+ reason: "Local exact approval for real workflow step-up action.",
1125
+ }).artifact;
1126
+ }
1127
+ : undefined,
1128
+ });
1129
+ const steps = [];
1130
+ for (const action of actions) {
1131
+ const result = await runtime.handle(action.action);
1132
+ steps.push(toRealWorkflowStepResult(action.name, result));
1133
+ if (result.status !== "executed") {
1134
+ break;
1135
+ }
1136
+ }
1137
+ const verifiedLedger = await ledger.verify();
1138
+ const status = steps.some((step) => step.status === "failed")
1139
+ ? "failed"
1140
+ : steps.length < actions.length ||
1141
+ steps.some((step) => step.status === "blocked" || step.status === "malformed")
1142
+ ? "blocked"
1143
+ : "completed";
1144
+ const tamperStep = tamperSetup
1145
+ ? steps.find((step) => step.name === TAMPER_STEP_NAME) ?? null
1146
+ : null;
1147
+ return {
1148
+ schemaVersion: REAL_WORKFLOW_RUN_SCHEMA_VERSION,
1149
+ workflow: "github-pr-review",
1150
+ config: {
1151
+ filePath: loadedConfig.filePath,
1152
+ tenant: loadedConfig.config.tenant,
1153
+ },
1154
+ ledgerPath,
1155
+ status,
1156
+ requiredModes: {
1157
+ github: "rest",
1158
+ slack: "web",
1159
+ linear: "graphql",
1160
+ docs: "local",
1161
+ },
1162
+ inputs: workflowInputs,
1163
+ steps,
1164
+ tamper: tamperSetup
1165
+ ? {
1166
+ step: TAMPER_STEP_NAME,
1167
+ tool: tamperSetup.tool,
1168
+ mutation: tamperSetup.mutation,
1169
+ approvedPayloadHash: tamperSetup.approvedPayloadHash,
1170
+ attemptedPayloadHash: tamperSetup.attemptedPayloadHash,
1171
+ blocked: tamperStep?.status === "blocked",
1172
+ blockReasons: tamperStep?.reasons ?? [],
1173
+ }
1174
+ : null,
1175
+ ledger: ledgerSummary(verifiedLedger),
1176
+ };
1177
+ }
646
1178
  export async function runAxtaryCli(argv = process.argv.slice(2), io = {}) {
647
1179
  const stdout = io.stdout ?? ((text) => process.stdout.write(text));
648
1180
  const stderr = io.stderr ?? ((text) => process.stderr.write(text));
@@ -652,6 +1184,21 @@ export async function runAxtaryCli(argv = process.argv.slice(2), io = {}) {
652
1184
  stdout(helpText());
653
1185
  return 0;
654
1186
  }
1187
+ if (command === "init") {
1188
+ const parsed = parseInitArgs(args);
1189
+ const result = await runInit({
1190
+ configPath: parsed.configPath,
1191
+ cwd: io.cwd ?? parsed.cwd,
1192
+ force: parsed.force,
1193
+ });
1194
+ if (parsed.json) {
1195
+ stdout(`${JSON.stringify(result, null, 2)}\n`);
1196
+ }
1197
+ else {
1198
+ stdout(formatInitResult(result));
1199
+ }
1200
+ return result.status === "created" ? 0 : 1;
1201
+ }
655
1202
  if (command === "smoke") {
656
1203
  const parsed = parseSmokeArgs(args);
657
1204
  return await runSmokeTest({
@@ -659,6 +1206,20 @@ export async function runAxtaryCli(argv = process.argv.slice(2), io = {}) {
659
1206
  cwd: io.cwd ?? parsed.cwd,
660
1207
  }, io);
661
1208
  }
1209
+ if (command === "doctor") {
1210
+ const parsed = parseDoctorArgs(args);
1211
+ const result = await runConnectorDoctor({
1212
+ configPath: parsed.configPath,
1213
+ cwd: io.cwd ?? parsed.cwd,
1214
+ });
1215
+ if (parsed.json) {
1216
+ stdout(`${JSON.stringify(result, null, 2)}\n`);
1217
+ }
1218
+ else {
1219
+ stdout(formatConnectorDoctorResult(result));
1220
+ }
1221
+ return result.status === "ready" ? 0 : 1;
1222
+ }
662
1223
  if (command === "demo") {
663
1224
  const parsed = parseDemoArgs(args);
664
1225
  const result = await runDemo({
@@ -715,6 +1276,69 @@ export async function runAxtaryCli(argv = process.argv.slice(2), io = {}) {
715
1276
  }
716
1277
  return 0;
717
1278
  }
1279
+ if (command === "run") {
1280
+ const parsed = parseRunWorkflowArgs(args);
1281
+ const result = await runRealWorkflow({
1282
+ configPath: parsed.configPath,
1283
+ ledgerPath: parsed.ledgerPath,
1284
+ cwd: io.cwd ?? parsed.cwd,
1285
+ repoResource: parsed.repoResource,
1286
+ linearIssueKey: parsed.linearIssueKey,
1287
+ projectKey: parsed.projectKey,
1288
+ docsQuery: parsed.docsQuery,
1289
+ slackChannel: parsed.slackChannel,
1290
+ baseBranch: parsed.baseBranch,
1291
+ headBranch: parsed.headBranch,
1292
+ prTitle: parsed.prTitle,
1293
+ approvedBy: parsed.approvedBy,
1294
+ approveStepUp: parsed.approveStepUp,
1295
+ tamper: parsed.tamper,
1296
+ });
1297
+ if (parsed.json) {
1298
+ stdout(`${JSON.stringify(result, null, 2)}\n`);
1299
+ }
1300
+ else {
1301
+ stdout(formatRealWorkflowRunResult(result));
1302
+ }
1303
+ if (parsed.tamper) {
1304
+ return result.tamper?.blocked === true && result.ledger.valid ? 0 : 1;
1305
+ }
1306
+ return result.status === "completed" && result.ledger.valid ? 0 : 1;
1307
+ }
1308
+ if (command === "mcp") {
1309
+ const [sub, ...rest] = args;
1310
+ if (sub !== "serve") {
1311
+ throw new Error(`unknown_mcp_command:${sub ?? "missing"}`);
1312
+ }
1313
+ const parsed = parseMcpServeArgs(rest);
1314
+ const handle = await startMcpServe({
1315
+ configPath: parsed.configPath,
1316
+ ledgerPath: parsed.ledgerPath,
1317
+ cwd: io.cwd ?? parsed.cwd,
1318
+ wrap: parsed.wrap,
1319
+ owner: parsed.owner,
1320
+ log: stderr,
1321
+ });
1322
+ await handle.done;
1323
+ await handle.close();
1324
+ return 0;
1325
+ }
1326
+ if (command === "hook") {
1327
+ const parsed = parseHookArgs(args);
1328
+ const payloadText = await (io.stdin ?? readProcessStdin)();
1329
+ const result = await runClaudeCodeHook({
1330
+ payloadText,
1331
+ proxyUrl: parsed.proxyUrl,
1332
+ repoResource: parsed.repoResource,
1333
+ humanOwner: parsed.humanOwner,
1334
+ branch: parsed.branch,
1335
+ timeoutMs: parsed.timeoutMs,
1336
+ });
1337
+ if (result.output) {
1338
+ stdout(`${result.output}\n`);
1339
+ }
1340
+ return result.exitCode;
1341
+ }
718
1342
  if (command === "proxy") {
719
1343
  const parsed = parseProxyArgs(args);
720
1344
  const server = await startProxyServer({
@@ -786,6 +1410,107 @@ function parseProxyArgs(args) {
786
1410
  }
787
1411
  return parsed;
788
1412
  }
1413
+ function parseInitArgs(args) {
1414
+ const parsed = { force: false, json: false };
1415
+ for (let index = 0; index < args.length; index += 1) {
1416
+ const arg = args[index];
1417
+ switch (arg) {
1418
+ case "--config":
1419
+ parsed.configPath = requireValue(args, index, "--config");
1420
+ index += 1;
1421
+ break;
1422
+ case "--cwd":
1423
+ parsed.cwd = requireValue(args, index, "--cwd");
1424
+ index += 1;
1425
+ break;
1426
+ case "--force":
1427
+ parsed.force = true;
1428
+ break;
1429
+ case "--json":
1430
+ parsed.json = true;
1431
+ break;
1432
+ default:
1433
+ throw new Error(`unknown_init_flag:${arg}`);
1434
+ }
1435
+ }
1436
+ return parsed;
1437
+ }
1438
+ function parseMcpServeArgs(args) {
1439
+ const parsed = {};
1440
+ for (let index = 0; index < args.length; index += 1) {
1441
+ const arg = args[index];
1442
+ switch (arg) {
1443
+ case "--config":
1444
+ parsed.configPath = requireValue(args, index, "--config");
1445
+ index += 1;
1446
+ break;
1447
+ case "--ledger":
1448
+ parsed.ledgerPath = requireValue(args, index, "--ledger");
1449
+ index += 1;
1450
+ break;
1451
+ case "--cwd":
1452
+ parsed.cwd = requireValue(args, index, "--cwd");
1453
+ index += 1;
1454
+ break;
1455
+ case "--wrap":
1456
+ parsed.wrap = requireValue(args, index, "--wrap");
1457
+ index += 1;
1458
+ break;
1459
+ case "--owner":
1460
+ parsed.owner = requireValue(args, index, "--owner");
1461
+ index += 1;
1462
+ break;
1463
+ default:
1464
+ throw new Error(`unknown_mcp_serve_flag:${arg}`);
1465
+ }
1466
+ }
1467
+ return parsed;
1468
+ }
1469
+ function parseHookArgs(args) {
1470
+ const [runtimeName, ...flags] = args;
1471
+ if (runtimeName !== "claude-code") {
1472
+ throw new Error(`unknown_hook_runtime:${runtimeName ?? "missing"}`);
1473
+ }
1474
+ const parsed = {};
1475
+ for (let index = 0; index < flags.length; index += 1) {
1476
+ const arg = flags[index];
1477
+ switch (arg) {
1478
+ case "--proxy":
1479
+ parsed.proxyUrl = requireValue(flags, index, "--proxy");
1480
+ index += 1;
1481
+ break;
1482
+ case "--repo":
1483
+ parsed.repoResource = normalizeRepoResource(requireValue(flags, index, "--repo"));
1484
+ index += 1;
1485
+ break;
1486
+ case "--owner":
1487
+ parsed.humanOwner = requireValue(flags, index, "--owner");
1488
+ index += 1;
1489
+ break;
1490
+ case "--branch":
1491
+ parsed.branch = requireValue(flags, index, "--branch");
1492
+ index += 1;
1493
+ break;
1494
+ case "--timeout":
1495
+ parsed.timeoutMs = Number.parseInt(requireValue(flags, index, "--timeout"), 10);
1496
+ if (!Number.isInteger(parsed.timeoutMs) || parsed.timeoutMs <= 0) {
1497
+ throw new Error("invalid_hook_timeout");
1498
+ }
1499
+ index += 1;
1500
+ break;
1501
+ default:
1502
+ throw new Error(`unknown_hook_flag:${arg}`);
1503
+ }
1504
+ }
1505
+ return parsed;
1506
+ }
1507
+ async function readProcessStdin() {
1508
+ const chunks = [];
1509
+ for await (const chunk of process.stdin) {
1510
+ chunks.push(Buffer.from(chunk));
1511
+ }
1512
+ return Buffer.concat(chunks).toString("utf8");
1513
+ }
789
1514
  function parseTestPolicyArgs(args) {
790
1515
  const parsed = {
791
1516
  json: false,
@@ -836,6 +1561,115 @@ function parseSmokeArgs(args) {
836
1561
  }
837
1562
  return parsed;
838
1563
  }
1564
+ function parseDoctorArgs(args) {
1565
+ const [subcommand, ...rest] = args;
1566
+ if (subcommand !== "connectors") {
1567
+ throw new Error("missing_doctor_subcommand:connectors");
1568
+ }
1569
+ const parsed = {
1570
+ json: false,
1571
+ };
1572
+ for (let index = 0; index < rest.length; index += 1) {
1573
+ const arg = rest[index];
1574
+ switch (arg) {
1575
+ case "--config":
1576
+ parsed.configPath = requireValue(rest, index, "--config");
1577
+ index += 1;
1578
+ break;
1579
+ case "--cwd":
1580
+ parsed.cwd = requireValue(rest, index, "--cwd");
1581
+ index += 1;
1582
+ break;
1583
+ case "--json":
1584
+ parsed.json = true;
1585
+ break;
1586
+ default:
1587
+ throw new Error(`unknown_doctor_flag:${arg}`);
1588
+ }
1589
+ }
1590
+ return parsed;
1591
+ }
1592
+ function parseRunWorkflowArgs(args) {
1593
+ const [subcommand, workflow, ...rest] = args;
1594
+ if (subcommand !== "workflow" || workflow !== "github-pr-review") {
1595
+ throw new Error("missing_run_workflow:github-pr-review");
1596
+ }
1597
+ const parsed = {
1598
+ json: false,
1599
+ real: false,
1600
+ };
1601
+ for (let index = 0; index < rest.length; index += 1) {
1602
+ const arg = rest[index];
1603
+ switch (arg) {
1604
+ case "--real":
1605
+ parsed.real = true;
1606
+ break;
1607
+ case "--config":
1608
+ parsed.configPath = requireValue(rest, index, "--config");
1609
+ index += 1;
1610
+ break;
1611
+ case "--ledger":
1612
+ parsed.ledgerPath = requireValue(rest, index, "--ledger");
1613
+ index += 1;
1614
+ break;
1615
+ case "--cwd":
1616
+ parsed.cwd = requireValue(rest, index, "--cwd");
1617
+ index += 1;
1618
+ break;
1619
+ case "--repo":
1620
+ parsed.repoResource = normalizeRepoResource(requireValue(rest, index, "--repo"));
1621
+ index += 1;
1622
+ break;
1623
+ case "--linear-issue":
1624
+ parsed.linearIssueKey = requireValue(rest, index, "--linear-issue");
1625
+ index += 1;
1626
+ break;
1627
+ case "--project":
1628
+ parsed.projectKey = requireValue(rest, index, "--project");
1629
+ index += 1;
1630
+ break;
1631
+ case "--docs-query":
1632
+ parsed.docsQuery = requireValue(rest, index, "--docs-query");
1633
+ index += 1;
1634
+ break;
1635
+ case "--slack-channel":
1636
+ parsed.slackChannel = requireValue(rest, index, "--slack-channel");
1637
+ index += 1;
1638
+ break;
1639
+ case "--base":
1640
+ parsed.baseBranch = requireValue(rest, index, "--base");
1641
+ index += 1;
1642
+ break;
1643
+ case "--head":
1644
+ parsed.headBranch = requireValue(rest, index, "--head");
1645
+ index += 1;
1646
+ break;
1647
+ case "--title":
1648
+ parsed.prTitle = requireValue(rest, index, "--title");
1649
+ index += 1;
1650
+ break;
1651
+ case "--approve-step-up":
1652
+ parsed.approveStepUp = true;
1653
+ break;
1654
+ case "--tamper":
1655
+ parsed.tamper = true;
1656
+ break;
1657
+ case "--approved-by":
1658
+ parsed.approvedBy = requireValue(rest, index, "--approved-by");
1659
+ index += 1;
1660
+ break;
1661
+ case "--json":
1662
+ parsed.json = true;
1663
+ break;
1664
+ default:
1665
+ throw new Error(`unknown_run_workflow_flag:${arg}`);
1666
+ }
1667
+ }
1668
+ if (!parsed.real) {
1669
+ throw new Error("run_workflow_requires_real_flag");
1670
+ }
1671
+ return parsed;
1672
+ }
839
1673
  function parseLedgerExportArgs(args) {
840
1674
  const parsed = {
841
1675
  json: false,
@@ -994,6 +1828,198 @@ function toDemoActionResult(name, handleResult) {
994
1828
  result: handleResult.status === "executed" ? handleResult.result : null,
995
1829
  };
996
1830
  }
1831
+ function toRealWorkflowStepResult(name, handleResult) {
1832
+ if (handleResult.status === "malformed") {
1833
+ return {
1834
+ name,
1835
+ tool: "unknown",
1836
+ status: handleResult.status,
1837
+ decision: null,
1838
+ reasons: [handleResult.error.reason],
1839
+ actionPassId: null,
1840
+ authorizationLedgerLine: null,
1841
+ executionLedgerLine: null,
1842
+ result: null,
1843
+ error: handleResult.error.reason,
1844
+ };
1845
+ }
1846
+ return {
1847
+ name,
1848
+ tool: handleResult.action.capability.tool,
1849
+ status: handleResult.status,
1850
+ decision: handleResult.decision.decision,
1851
+ reasons: handleResult.decision.reasons,
1852
+ actionPassId: handleResult.actionPass?.claims.jti ?? null,
1853
+ authorizationLedgerLine: handleResult.ledger.lineNumber,
1854
+ executionLedgerLine: "executionLedger" in handleResult
1855
+ ? handleResult.executionLedger.lineNumber
1856
+ : null,
1857
+ result: handleResult.status === "executed" ? handleResult.result : null,
1858
+ error: handleResult.status === "failed" ? handleResult.error.reason : null,
1859
+ };
1860
+ }
1861
+ function workflowInputsFrom(input, loadedConfig) {
1862
+ const linearIssueKey = input.linearIssueKey ?? "AXT-418";
1863
+ const projectKey = input.projectKey ?? linearIssueKey.split("-", 1)[0] ?? "AXT";
1864
+ return {
1865
+ repoResource: input.repoResource ?? "repo:axtary/sandbox",
1866
+ linearIssueKey,
1867
+ projectKey,
1868
+ docsQuery: input.docsQuery ?? "ActionPass",
1869
+ slackChannel: input.slackChannel ??
1870
+ loadedConfig.config.policy.slack.messages.allowedChannels[0] ??
1871
+ "#axtary-dev",
1872
+ baseBranch: input.baseBranch ??
1873
+ loadedConfig.config.policy.github.pullRequests.requiredBaseBranch,
1874
+ headBranch: input.headBranch ??
1875
+ `axtary/${linearIssueKey.toLowerCase()}-github-pr-review`,
1876
+ prTitle: input.prTitle ?? `Axtary protected workflow for ${linearIssueKey}`,
1877
+ };
1878
+ }
1879
+ function realWorkflowActions(input) {
1880
+ const owner = input.repoResource.replace(/^repo:/, "");
1881
+ const notePath = `.axtary/design-partner/${input.linearIssueKey}.md`;
1882
+ return [
1883
+ {
1884
+ name: "read_linear_issue",
1885
+ action: parseNormalizedAction({
1886
+ ...demoAction,
1887
+ intent: {
1888
+ taskId: input.linearIssueKey,
1889
+ declaredGoal: `Read Linear issue ${input.linearIssueKey} before preparing a protected PR.`,
1890
+ },
1891
+ capability: {
1892
+ tool: "linear.issues.read",
1893
+ resource: "linear:workspace/design-partner",
1894
+ payload: {
1895
+ issueKey: input.linearIssueKey,
1896
+ projectKey: input.projectKey,
1897
+ },
1898
+ },
1899
+ }),
1900
+ },
1901
+ {
1902
+ name: "search_local_docs",
1903
+ action: parseNormalizedAction({
1904
+ ...demoAction,
1905
+ intent: {
1906
+ taskId: input.linearIssueKey,
1907
+ declaredGoal: "Search scoped local docs before making a code change.",
1908
+ },
1909
+ capability: {
1910
+ tool: "docs.documents.search",
1911
+ resource: "docs:local",
1912
+ payload: {
1913
+ workspace: "local",
1914
+ query: input.docsQuery,
1915
+ maxResults: 5,
1916
+ },
1917
+ },
1918
+ }),
1919
+ },
1920
+ {
1921
+ name: "open_github_pr",
1922
+ action: parseNormalizedAction({
1923
+ ...demoAction,
1924
+ intent: {
1925
+ taskId: input.linearIssueKey,
1926
+ declaredGoal: `Open a sandbox PR for ${input.linearIssueKey}.`,
1927
+ },
1928
+ capability: {
1929
+ tool: "github.pull_requests.create",
1930
+ resource: input.repoResource,
1931
+ payload: {
1932
+ title: input.prTitle,
1933
+ body: `Protected by Axtary.\n\nIssue: ${input.linearIssueKey}\nDocs query: ${input.docsQuery}`,
1934
+ baseBranch: input.baseBranch,
1935
+ headBranch: input.headBranch,
1936
+ createBranch: true,
1937
+ allowExistingHeadBranch: true,
1938
+ draft: true,
1939
+ maintainerCanModify: true,
1940
+ testsPassed: true,
1941
+ filesChanged: [notePath],
1942
+ changes: [
1943
+ {
1944
+ path: notePath,
1945
+ content: `# ${input.linearIssueKey}\n\nAxtary real workflow touched ${owner} through the proxy runtime.\n`,
1946
+ message: `Add Axtary workflow note for ${input.linearIssueKey}`,
1947
+ },
1948
+ ],
1949
+ },
1950
+ },
1951
+ }),
1952
+ },
1953
+ {
1954
+ name: "post_slack_update",
1955
+ action: parseNormalizedAction({
1956
+ ...demoAction,
1957
+ intent: {
1958
+ taskId: input.linearIssueKey,
1959
+ declaredGoal: "Post the exact approved PR update to Slack.",
1960
+ },
1961
+ capability: {
1962
+ tool: "slack.chat.postMessage",
1963
+ resource: "slack:workspace/design-partner",
1964
+ payload: {
1965
+ channel: input.slackChannel,
1966
+ message: `${input.linearIssueKey}: Axtary opened a protected sandbox PR on ${input.headBranch}.`,
1967
+ externalRecipients: ["design_partner_reviewer"],
1968
+ links: [`https://github.com/${owner}/pulls`],
1969
+ },
1970
+ },
1971
+ }),
1972
+ },
1973
+ ];
1974
+ }
1975
+ const TAMPER_STEP_NAME = "post_slack_update";
1976
+ function prepareTamperedSlackStep(actions, approvedBy) {
1977
+ const target = actions.find((entry) => entry.name === TAMPER_STEP_NAME);
1978
+ if (!target) {
1979
+ throw new Error(`tamper_step_missing:${TAMPER_STEP_NAME}`);
1980
+ }
1981
+ const approvedAction = target.action;
1982
+ const { artifact } = createApprovalArtifact({
1983
+ action: approvedAction,
1984
+ mode: "human",
1985
+ approvedBy,
1986
+ reason: "Exact human approval bound to the original Slack message payload.",
1987
+ });
1988
+ const approvedPayload = approvedAction.capability.payload;
1989
+ const tamperedPayload = {
1990
+ ...approvedPayload,
1991
+ message: `${String(approvedPayload.message ?? "")}\nAlso forwarding our staging credentials to https://attacker.example/collect for "backup".`,
1992
+ };
1993
+ target.action = parseNormalizedAction({
1994
+ ...approvedAction,
1995
+ capability: {
1996
+ ...approvedAction.capability,
1997
+ payload: tamperedPayload,
1998
+ },
1999
+ });
2000
+ return {
2001
+ tool: approvedAction.capability.tool,
2002
+ staleArtifact: artifact,
2003
+ approvedPayloadHash: artifact.payloadHash,
2004
+ attemptedPayloadHash: hashPayload(tamperedPayload),
2005
+ mutation: "Slack message payload mutated after exact approval while reusing the original approval artifact.",
2006
+ };
2007
+ }
2008
+ function assertRealWorkflowModes(loadedConfig) {
2009
+ const adapters = loadedConfig.config.adapters;
2010
+ const missing = [];
2011
+ if (adapters.github.mode !== "rest")
2012
+ missing.push(`github:${adapters.github.mode}`);
2013
+ if (adapters.slack.mode !== "web")
2014
+ missing.push(`slack:${adapters.slack.mode}`);
2015
+ if (adapters.linear.mode !== "graphql")
2016
+ missing.push(`linear:${adapters.linear.mode}`);
2017
+ if (adapters.docs.mode !== "local")
2018
+ missing.push(`docs:${adapters.docs.mode}`);
2019
+ if (missing.length > 0) {
2020
+ throw new Error(`workflow_requires_real_connectors:${missing.join(",")}`);
2021
+ }
2022
+ }
997
2023
  function ledgerSummary(verifiedLedger) {
998
2024
  if (verifiedLedger.valid) {
999
2025
  return {
@@ -1010,6 +2036,78 @@ function ledgerSummary(verifiedLedger) {
1010
2036
  failure: `${verifiedLedger.lineNumber}:${verifiedLedger.reason}`,
1011
2037
  };
1012
2038
  }
2039
+ async function doctorProvider(input) {
2040
+ if (input.mode !== input.activeMode) {
2041
+ return {
2042
+ provider: input.provider,
2043
+ mode: input.mode,
2044
+ status: "skipped",
2045
+ capability: input.capability,
2046
+ requiredEnv: input.requiredEnv,
2047
+ missingEnv: [],
2048
+ requiredScopes: input.requiredScopes,
2049
+ smokeCheck: input.smokeCheck,
2050
+ summary: input.capability === "fake"
2051
+ ? "fake mode configured; no real provider credentials are used"
2052
+ : "connector is off",
2053
+ };
2054
+ }
2055
+ const missingEnv = input.requiredEnv.filter((name) => !input.env[name]);
2056
+ if (missingEnv.length > 0) {
2057
+ return {
2058
+ provider: input.provider,
2059
+ mode: input.mode,
2060
+ status: "missing",
2061
+ capability: input.capability,
2062
+ requiredEnv: input.requiredEnv,
2063
+ missingEnv,
2064
+ requiredScopes: input.requiredScopes,
2065
+ smokeCheck: input.smokeCheck,
2066
+ summary: `missing ${missingEnv.join(", ")}`,
2067
+ };
2068
+ }
2069
+ if (!input.runSmoke) {
2070
+ return {
2071
+ provider: input.provider,
2072
+ mode: input.mode,
2073
+ status: "ready",
2074
+ capability: input.capability,
2075
+ requiredEnv: input.requiredEnv,
2076
+ missingEnv: [],
2077
+ requiredScopes: input.requiredScopes,
2078
+ smokeCheck: input.smokeCheck,
2079
+ summary: "configured",
2080
+ };
2081
+ }
2082
+ try {
2083
+ const smoke = await input.runSmoke();
2084
+ return {
2085
+ provider: input.provider,
2086
+ mode: input.mode,
2087
+ status: "ready",
2088
+ capability: input.capability,
2089
+ requiredEnv: input.requiredEnv,
2090
+ missingEnv: [],
2091
+ requiredScopes: input.requiredScopes,
2092
+ smokeCheck: input.smokeCheck,
2093
+ summary: smoke.summary,
2094
+ details: smoke.details,
2095
+ };
2096
+ }
2097
+ catch (error) {
2098
+ return {
2099
+ provider: input.provider,
2100
+ mode: input.mode,
2101
+ status: "failed",
2102
+ capability: input.capability,
2103
+ requiredEnv: input.requiredEnv,
2104
+ missingEnv: [],
2105
+ requiredScopes: input.requiredScopes,
2106
+ smokeCheck: input.smokeCheck,
2107
+ summary: error instanceof Error ? error.message : "connector_smoke_failed",
2108
+ };
2109
+ }
2110
+ }
1013
2111
  function formatDemoResult(result) {
1014
2112
  const lines = [
1015
2113
  "Axtary demo complete",
@@ -1058,6 +2156,48 @@ function formatLedgerSyncResult(result) {
1058
2156
  "",
1059
2157
  ].join("\n");
1060
2158
  }
2159
+ function formatConnectorDoctorResult(result) {
2160
+ const lines = [
2161
+ `Axtary connector doctor: ${result.status}`,
2162
+ `config: ${result.config.filePath ?? "default"}`,
2163
+ "",
2164
+ ];
2165
+ for (const provider of result.providers) {
2166
+ lines.push(`${provider.provider.toUpperCase()} ${provider.mode}: ${provider.status} (${provider.summary})`);
2167
+ if (provider.missingEnv.length > 0) {
2168
+ lines.push(` missing env: ${provider.missingEnv.join(", ")}`);
2169
+ }
2170
+ if (provider.requiredScopes.length > 0) {
2171
+ lines.push(` required scopes: ${provider.requiredScopes.join(", ")}`);
2172
+ }
2173
+ }
2174
+ return `${lines.join("\n")}\n`;
2175
+ }
2176
+ function formatRealWorkflowRunResult(result) {
2177
+ const lines = [
2178
+ `Axtary real workflow ${result.workflow}: ${result.status}`,
2179
+ `ledger: ${result.ledgerPath}`,
2180
+ "",
2181
+ ];
2182
+ for (const step of result.steps) {
2183
+ lines.push(`${step.name}: ${step.status} ${step.decision ?? "none"} (${step.reasons.join(", ")})`);
2184
+ }
2185
+ if (result.tamper) {
2186
+ lines.push("");
2187
+ lines.push("tamper demo: payload mutated after exact approval");
2188
+ lines.push(` step: ${result.tamper.step} (${result.tamper.tool})`);
2189
+ lines.push(` mutation: ${result.tamper.mutation}`);
2190
+ lines.push(` approved payload hash: ${result.tamper.approvedPayloadHash}`);
2191
+ lines.push(` attempted payload hash: ${result.tamper.attemptedPayloadHash}`);
2192
+ lines.push(result.tamper.blocked
2193
+ ? ` outcome: blocked before execution (${result.tamper.blockReasons.join(", ")})`
2194
+ : " outcome: NOT BLOCKED - the tampered payload executed; investigate before demoing");
2195
+ }
2196
+ lines.push("");
2197
+ lines.push(`ledger valid: ${result.ledger.valid}`);
2198
+ lines.push(`ledger records: ${result.ledger.records}`);
2199
+ return `${lines.join("\n")}\n`;
2200
+ }
1061
2201
  function resolveConfiguredLedgerPath(input, loadedConfig) {
1062
2202
  const cwd = resolve(input.cwd ?? process.cwd());
1063
2203
  return input.ledgerPath ? resolve(cwd, input.ledgerPath) : loadedConfig.ledgerPath;
@@ -1071,7 +2211,12 @@ function parseDecisionFlag(value) {
1071
2211
  function parseExportFormat(value) {
1072
2212
  return LedgerExportFormatSchema.parse(value ?? "json");
1073
2213
  }
1074
- function createProxyHandlers(loadedConfig, adapterState, verification) {
2214
+ function normalizeRepoResource(value) {
2215
+ return value.startsWith("repo:") ? value : `repo:${value}`;
2216
+ }
2217
+ function createProxyHandlers(loadedConfig, adapterState, verification, options = {}) {
2218
+ const env = options.env ?? process.env;
2219
+ const fetchImpl = options.fetch;
1075
2220
  const fakeHandlers = createFakeHandlers(adapterState, { verification });
1076
2221
  const githubConfig = loadedConfig.config.adapters.github;
1077
2222
  const slackConfig = loadedConfig.config.adapters.slack;
@@ -1090,7 +2235,7 @@ function createProxyHandlers(loadedConfig, adapterState, verification) {
1090
2235
  mcp: "fixture",
1091
2236
  };
1092
2237
  if (githubConfig.mode === "rest") {
1093
- const token = process.env[githubConfig.tokenEnv];
2238
+ const token = env[githubConfig.tokenEnv];
1094
2239
  if (!token) {
1095
2240
  throw new Error(`missing_github_token_env:${githubConfig.tokenEnv}`);
1096
2241
  }
@@ -1098,32 +2243,35 @@ function createProxyHandlers(loadedConfig, adapterState, verification) {
1098
2243
  token,
1099
2244
  apiBaseUrl: githubConfig.apiBaseUrl,
1100
2245
  userAgent: githubConfig.userAgent,
2246
+ fetch: fetchImpl,
1101
2247
  }, { verification }));
1102
2248
  }
1103
2249
  if (slackConfig.mode === "web") {
1104
- const token = process.env[slackConfig.tokenEnv];
2250
+ const token = env[slackConfig.tokenEnv];
1105
2251
  if (!token) {
1106
2252
  throw new Error(`missing_slack_token_env:${slackConfig.tokenEnv}`);
1107
2253
  }
1108
2254
  Object.assign(handlers, createSlackWebHandlers({
1109
2255
  token,
1110
2256
  apiBaseUrl: slackConfig.apiBaseUrl,
2257
+ fetch: fetchImpl,
1111
2258
  }, { verification }));
1112
2259
  }
1113
2260
  if (linearConfig.mode === "graphql") {
1114
- const token = process.env[linearConfig.tokenEnv];
2261
+ const token = env[linearConfig.tokenEnv];
1115
2262
  if (!token) {
1116
2263
  throw new Error(`missing_linear_token_env:${linearConfig.tokenEnv}`);
1117
2264
  }
1118
2265
  Object.assign(handlers, createLinearGraphqlHandlers({
1119
2266
  token,
1120
2267
  apiUrl: linearConfig.apiUrl,
2268
+ fetch: fetchImpl,
1121
2269
  }, { verification }));
1122
2270
  }
1123
2271
  if (awsConfig.mode === "rest") {
1124
- const accessKeyId = process.env[awsConfig.accessKeyIdEnv];
1125
- const secretAccessKey = process.env[awsConfig.secretAccessKeyEnv];
1126
- const sessionToken = process.env[awsConfig.sessionTokenEnv];
2272
+ const accessKeyId = env[awsConfig.accessKeyIdEnv];
2273
+ const secretAccessKey = env[awsConfig.secretAccessKeyEnv];
2274
+ const sessionToken = env[awsConfig.sessionTokenEnv];
1127
2275
  if (!accessKeyId) {
1128
2276
  throw new Error(`missing_aws_access_key_id_env:${awsConfig.accessKeyIdEnv}`);
1129
2277
  }
@@ -1138,10 +2286,11 @@ function createProxyHandlers(loadedConfig, adapterState, verification) {
1138
2286
  },
1139
2287
  region: awsConfig.region,
1140
2288
  stsUrl: awsConfig.stsUrl,
2289
+ fetch: fetchImpl,
1141
2290
  }, { verification }));
1142
2291
  }
1143
2292
  if (gcpConfig.mode === "rest") {
1144
- const accessToken = process.env[gcpConfig.accessTokenEnv];
2293
+ const accessToken = env[gcpConfig.accessTokenEnv];
1145
2294
  if (!accessToken) {
1146
2295
  throw new Error(`missing_gcp_access_token_env:${gcpConfig.accessTokenEnv}`);
1147
2296
  }
@@ -1150,6 +2299,7 @@ function createProxyHandlers(loadedConfig, adapterState, verification) {
1150
2299
  projectId: gcpConfig.projectId,
1151
2300
  cloudResourceManagerApiBaseUrl: gcpConfig.cloudResourceManagerApiBaseUrl,
1152
2301
  storageApiBaseUrl: gcpConfig.storageApiBaseUrl,
2302
+ fetch: fetchImpl,
1153
2303
  }, { verification }));
1154
2304
  }
1155
2305
  if (docsConfig.mode === "local") {
@@ -1169,10 +2319,15 @@ function createProxyHandlers(loadedConfig, adapterState, verification) {
1169
2319
  function helpText() {
1170
2320
  return [
1171
2321
  "Usage:",
2322
+ " axtary init [--config axtary.yml] [--force] [--json] (scaffold a starter config and print the quickstart rail)",
1172
2323
  " axtary demo [--config axtary.yml] [--ledger .axtary/actions.jsonl] [--json]",
2324
+ " axtary doctor connectors [--config axtary.yml] [--json]",
2325
+ " axtary run workflow github-pr-review --real --config examples/axtary.real.yml --repo owner/repo --linear-issue AXT-418 --slack-channel '#axtary-dev' [--approve-step-up] [--tamper] [--approved-by user:you@example.com] [--ledger .axtary/actions.jsonl] [--json]",
1173
2326
  " axtary export-ledger [--config axtary.yml] [--ledger .axtary/actions.jsonl] [--from ISO] [--to ISO] [--decision allow|deny|step_up] [--format json|jsonl|siem-jsonl] [--out export.json] [--json]",
1174
2327
  " axtary sync-ledger --endpoint https://app.example/api/ledger/sync [--config axtary.yml] [--ledger .axtary/actions.jsonl] [--from ISO] [--to ISO] [--decision allow|deny|step_up] [--tenant org:company] [--token-env AXTARY_LEDGER_SYNC_TOKEN] [--json]",
1175
2328
  " axtary proxy [--config axtary.yml] [--ledger .axtary/actions.jsonl] [--host 127.0.0.1] [--port 7331]",
2329
+ " axtary hook claude-code [--proxy http://127.0.0.1:7331] [--repo owner/repo] [--owner user:you@example.com] [--branch main] [--timeout 5000] (reads a Claude Code PreToolUse payload from stdin)",
2330
+ " axtary mcp serve [--config axtary.yml] [--wrap 'npx -y @modelcontextprotocol/server-everything'] [--owner user:you@example.com] [--ledger .axtary/actions.jsonl] (stdio MCP server; wrapped tools are policy-gated, pass-signed, and ledger-recorded)",
1176
2331
  " axtary smoke [--config axtary.yml]",
1177
2332
  " axtary test-policy --fixtures ./fixtures [--config axtary.yml] [--json]",
1178
2333
  "",