ikanban 0.4.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,21 @@
1
1
  # ikanban
2
2
 
3
3
  The iKanban command line. It scans your own repo on your machine and, when you ask, files the
4
- results into [iKanban](https://i-kanban.com).
4
+ findings into [iKanban](https://i-kanban.com). Full reports and raw tool output stay on your
5
+ machine, encrypted (see "Where scans are stored").
5
6
 
6
7
  ```bash
7
8
  npx ikanban secscan # dependencies, committed secrets, risky file permissions
8
9
  npx ikanban aiscan --help # red-team scan of your AI app (OWASP LLM Top 10)
9
- npx ikanban secscan --publish # also file the report and findings into iKanban
10
+ npx ikanban secscan --publish # also file the findings into iKanban
10
11
  npx ikanban infrascan --image app:1 # IaC misconfiguration and container image scan (Trivy)
11
12
  npx ikanban dastscan --url URL # passive web scan of a registered URL (OWASP ZAP in Docker)
12
13
  npx ikanban dastscan --url URL --openapi openapi.json # API scan of a registered URL (ZAP, active)
13
14
  npx ikanban cloudscan --aws-profile P --region R --account ID # read-only AWS account audit (Prowler)
15
+ npx ikanban controlscan --path . # repository controls: CI audits, lockfiles, secret scanning, CODEOWNERS…
16
+ npx ikanban import codeql.sarif --publish --team TEAM_ID # another scanner's SARIF file as findings
17
+ npx ikanban scans list # the stored scans of this repository
18
+ npx ikanban scans show <id> # print a stored report (decrypted on this machine only)
14
19
  npx ikanban secscan --task IKA-123 # also show the run in that task's Pipelines
15
20
  npx ikanban roadmap sync plan.json # make the Roadmap plan match the plan file
16
21
  npx ikanban hooks install # sync plan files on every agent write and git push
@@ -73,17 +78,57 @@ peer conflict), `secscan` writes a lockfile for a copy of `package.json` in its
73
78
  `.npmrc`) and audits that copy. Nothing is written into the repository. If npm still cannot
74
79
  resolve the tree, the folder is SKIPPED with the fix: commit a `package-lock.json`.
75
80
 
76
- ## Scan files
81
+ ## Where scans are stored
77
82
 
78
- Every scanner keeps its raw tool output and its report in `.iKanban/scans/<scanner>-<time>/` in
79
- the repository (`--work-dir PATH` or `IKANBAN_SCAN_DIR` to put it elsewhere). The folder holds its
80
- own `.gitignore`, so nothing in it is committed. Delete old runs whenever you like.
83
+ Each scanner writes its report and raw tool output into a private temporary folder. When it
84
+ ends, `ikanban` encrypts every file (AES-256-GCM) into
85
+ `~/.ikanban/scans/<repo>-<8 hex>/<scanner>-<UTC time>/` (Windows: `%USERPROFILE%\.ikanban\scans`)
86
+ and deletes the temporary folder. Nothing is written into the repository. `manifest.json` next to
87
+ the encrypted files lists only file names, sizes, the time, the scanner and the repository path.
88
+
89
+ ```bash
90
+ ikanban scans list [--repo PATH | --all] # stored scans (default: this folder's repository)
91
+ ikanban scans show <id> [file] [--out FILE] # decrypt to the terminal (default report.md)
92
+ ikanban scans migrate [--path PATH] # seal old .iKanban/scans folders, remove the plain copies
93
+ ```
94
+
95
+ The key is 32 random bytes, made on first use, and kept by the operating system:
96
+
97
+ - macOS: the login keychain (service `ikanban-scan-store`).
98
+ - Windows: DPAPI for the current user; the protected key is `%USERPROFILE%\.ikanban\scan-store.key.dpapi`.
99
+ - Linux: the Secret Service through `secret-tool` (service `ikanban-scan-store`).
100
+ - Anywhere else, or when the keychain fails, or with `IKANBAN_SCAN_STORE=file` (CI runners): an
101
+ owner-only file `~/.ikanban/scan-store.key`, and `ikanban` prints one line saying so.
102
+
103
+ The key is never printed or put on a command line. A changed file or another machine's key gives
104
+ a clear error, never garbled output. With `--work-dir PATH` the run folder in PATH is sealed and
105
+ removed too. Under `ikanban`, a scanner prints its progress, the counts and
106
+ `Full report: ikanban scans show <id>`; add `--print-report` to print the report itself. Running a
107
+ `.py` scanner directly with Python keeps the old behaviour: the report prints, and the files stay
108
+ in `.iKanban/scans/`.
109
+
110
+ ## Repository controls
111
+
112
+ `controlscan` checks eight controls of the repository and gives each pass, warn, fail or skipped
113
+ with the reason: a CI dependency audit (or Dependabot / Renovate), committed lockfiles, CI secret
114
+ scanning, CI static analysis (SAST), no `.env` or key file in git, default-branch protection,
115
+ CODEOWNERS and SECURITY.md. Branch protection, and GitHub secret scanning when CI has no secret
116
+ scanner, are read with your own `gh` sign-in; without `gh` that check is skipped, never passed.
117
+ `--publish` files one result per control; a failed control files one Medium finding, a warning
118
+ one Low finding.
119
+
120
+ ## Import another scanner's results
121
+
122
+ `ikanban import FILE.sarif` reads a SARIF 2.1.0 file (CodeQL, Semgrep, Trivy, Snyk…). A result
123
+ whose rule carries a CWE becomes a code finding under the OWASP Top 10 2021 category that lists
124
+ that CWE. A result with no CWE, or a CWE no category lists, stays in the local report as
125
+ "not filed: no OWASP category". The SARIF file is never uploaded.
81
126
 
82
127
  ## Pipelines
83
128
 
84
129
  Every scanner takes `--task IKA-123` (or `IKANBAN_TASK`) and `--task-team` (default `--team`). The
85
130
  run then shows in that task's Pipelines view: running, then passed, failed or skipped with a
86
- one-line summary, and the masked report as its log. While the scan runs, the stage log gets one
131
+ one-line summary. The report itself is never sent there. While the scan runs, the stage log gets one
87
132
  line as each step starts and ends. A plan without Pipelines never fails the scan.
88
133
 
89
134
  ## What you need
@@ -100,8 +145,11 @@ iKanban checks your plan when you publish; a plan that does not include it gets
100
145
 
101
146
  ## What it sends
102
147
 
103
- Nothing, unless you pass `--publish`. With `--publish`, the report and its findings go to iKanban.
104
- Secrets found in your repo are masked in the report before anything is sent.
148
+ Nothing, unless you pass `--publish`. With `--publish`, only findings go to iKanban: title,
149
+ category, severity, file and line, and the fix. Every text field is scrubbed on your machine first
150
+ (secrets, keys and tokens cut, your home folder written as `~`), and again on the server. AI scan
151
+ evidence (the attack, the answer, the grader's reason) is cut to 2,000 characters. Full reports,
152
+ raw tool output and SARIF files stay on your machine.
105
153
 
106
154
  ## Licence
107
155
 
package/bin/ikanban.js CHANGED
@@ -4,8 +4,10 @@
4
4
  // Scans run free on your machine; only --publish talks to iKanban, and the
5
5
  // server checks your plan there (a 402 means the plan, not the scan).
6
6
  const path = require("node:path");
7
- const { runCommand } = require("../lib/ikanban-safe.js");
8
7
  const { version } = require("../package.json");
8
+ // Loaded when a scan needs it: `ikanban plan check` must run from a plain checkout, where the
9
+ // copied lib/ikanban-safe.js is missing (the plan gate tells agents to run it there).
10
+ const runCommand = (...a) => require("../lib/ikanban-safe.js").runCommand(...a);
9
11
 
10
12
  const COMMANDS = {
11
13
  aiscan: { file: "ikanban-aiscan.py", about: "Red-team scan of your AI app (OWASP LLM Top 10)" },
@@ -13,10 +15,12 @@ const COMMANDS = {
13
15
  infrascan: { file: "ikanban-infrascan.py", about: "Container image and IaC scan (needs Trivy)" },
14
16
  dastscan: { file: "ikanban-dastscan.py", about: "Web or API scan of a registered URL (needs Docker)" },
15
17
  cloudscan: { file: "ikanban-cloudscan.py", about: "Read-only AWS account audit (needs AWS CLI, Docker)" },
18
+ controlscan: { file: "ikanban-controlscan.py", about: "Repository security controls: CI audits, lockfiles, secret scanning, CODEOWNERS…" },
19
+ import: { file: "ikanban-import.py", about: "Turn another scanner's SARIF file into findings: import FILE.sarif" },
16
20
  };
17
21
 
18
22
  function help() {
19
- const lines = Object.entries(COMMANDS).map(([n, c]) => ` ${n.padEnd(10)}${c.about}`);
23
+ const lines = Object.entries(COMMANDS).map(([n, c]) => ` ${n.padEnd(12)}${c.about}`);
20
24
  console.log(`ikanban ${version}
21
25
 
22
26
  Usage: ikanban <command> [options]
@@ -25,12 +29,14 @@ Commands:
25
29
  ${lines.join("\n")}
26
30
  roadmap Keep a Roadmap plan in step with its plan file: roadmap sync [plan.json] [--check]
27
31
  hooks Install the hooks that run that sync on every agent write and git push
32
+ plan Check a plan file against the template: plan check FILE [--done]
28
33
 
29
34
  pipeline Report a task's stage and stream its log to iKanban (any CI)
30
35
 
31
36
  Run "ikanban <command> --help" for its options. Add --publish to file the
32
- results into iKanban (needs an API token; the server checks your plan), and
33
- --task IKA-123 to show the run in that task's Pipelines.
37
+ findings into iKanban (needs an API token; the server checks your plan), and
38
+ --task IKA-123 to show the run in that task's Pipelines. Full reports and raw tool
39
+ output stay on this machine, encrypted in ~/.ikanban/scans ("ikanban scans").
34
40
  Needs Python 3.9 or newer.`);
35
41
  }
36
42
 
@@ -62,6 +68,14 @@ if (cmd === "roadmap" || cmd === "hooks") {
62
68
  });
63
69
  return;
64
70
  }
71
+ // Plan 7 P1: one shared definition of "a plan that passes", for every gate.
72
+ if (cmd === "plan") {
73
+ Promise.resolve(require("../lib/plancheck.js").run(args)).then((code) => process.exit(code), (e) => {
74
+ console.error(`ikanban plan: ${e.message}`);
75
+ process.exit(1);
76
+ });
77
+ return;
78
+ }
65
79
  // Plan 21 F10: report a task's pipeline stages and stream their logs, from any CI.
66
80
  if (cmd === "pipeline") {
67
81
  require("../lib/pipeline.js").run(args).then((code) => process.exit(code), (e) => {
@@ -70,6 +84,10 @@ if (cmd === "pipeline") {
70
84
  });
71
85
  return;
72
86
  }
87
+ // Plan 5 X3: stored scans, decrypted on this machine only.
88
+ if (cmd === "scans") {
89
+ process.exit(require("../lib/scanstore.js").run(args));
90
+ }
73
91
  const command = COMMANDS[cmd];
74
92
  if (!command) {
75
93
  console.error(`ikanban: unknown command "${cmd}". Run "ikanban --help".`);
@@ -84,5 +102,30 @@ const [py, pre] = python;
84
102
  const script = path.join(__dirname, "..", "scanners", command.file);
85
103
  // Windows consoles default to cp1252, which lacks characters the reports print.
86
104
  const env = { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" };
87
- const r = runCommand(py, [...pre, script, ...args], { stdio: "inherit", env });
88
- process.exit(r.status === null ? 1 : r.status);
105
+ // Plan 5 X3: the scanner writes into a private temp folder (IKANBAN_SCAN_DIR); afterwards every
106
+ // file is sealed into ~/.ikanban/scans and the folder is removed. The exit code is the scanner's.
107
+ const optionValue = (name) => {
108
+ const i = args.indexOf(name);
109
+ if (i >= 0) return args[i + 1];
110
+ const eq = args.find((a) => a.startsWith(`${name}=`));
111
+ return eq ? eq.slice(name.length + 1) : undefined;
112
+ };
113
+ const repoPath = path.resolve(optionValue("--path") || ".");
114
+ const workDir = optionValue("--work-dir"); // an explicit --work-dir run folder is sealed too
115
+ const started = Date.now() - 2000;
116
+ const extraDirs = () => {
117
+ if (!workDir) return [];
118
+ const fs = require("node:fs");
119
+ const dir = path.resolve(workDir);
120
+ if (!fs.existsSync(dir)) return [];
121
+ const runDir = new RegExp(`^${cmd}-\\d{8}-\\d{6}$`);
122
+ return fs.readdirSync(dir).filter((n) => runDir.test(n)).map((n) => path.join(dir, n))
123
+ .filter((p) => fs.statSync(p).isDirectory() && fs.statSync(p).mtimeMs >= started);
124
+ };
125
+ const store = require("../lib/scanstore.js").createStore();
126
+ process.exit(store.runSealed({
127
+ scanner: cmd,
128
+ repoPath,
129
+ extraDirs,
130
+ run: (scanEnv) => runCommand(py, [...pre, script, ...args], { stdio: "inherit", env: { ...env, ...scanEnv } }).status,
131
+ }));
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ // iKanban: after a deploy, check what the site serves before anyone reads it (plan 35 V1-T4).
3
+ // An agent hook runs this after each shell command (Claude Code PostToolUse, Gemini CLI
4
+ // AfterTool, Copilot CLI postToolUse; `npx ikanban hooks install` wires them). When the command
5
+ // contains one of the deploy commands in .iKanban/settings-local.json "deploy_check":
6
+ // 1. Bundle guard: it reads the site's build marker (build_url) and says PASS when the marker
7
+ // names the commit just deployed, else STALE: no reading of the site until it does.
8
+ // 2. Intent check: it asks the agent to open site_url and show that the change does what the
9
+ // task asked, before it reports the deploy as done.
10
+ // It runs no git command (it reads .git directly) and never blocks the agent: it always exits 0;
11
+ // problems go to stderr. V13-T10: with the kit's api_url, api_key and team in settings-local.json,
12
+ // it reports each result (pass, stale, intent) to iKanban for Observification; the key goes only
13
+ // to iKanban's API (ikanbanFetch). The task is IKANBAN_TASK or the key in the branch name.
14
+ // V16-T14: with those settings, iKanban decides (Agent settings → Pipelines → Deploy check): Off there
15
+ // is off here, and its commands and addresses replace the local "deploy_check" block. The local block
16
+ // is used only without the kit's API settings, or when iKanban does not answer and no copy is saved.
17
+ const fs = require("node:fs");
18
+ const os = require("node:os");
19
+ const path = require("node:path");
20
+ const { ikanbanFetch, teamId } = require("./ikanban-safe.js");
21
+
22
+ const TIMEOUT_MS = 8000;
23
+ // The app's settings are read once in 5 minutes: the hook runs after every shell command.
24
+ const SETTINGS_TTL_MS = 5 * 60 * 1000;
25
+
26
+ function repoRoot(cwd) {
27
+ for (let dir = fs.realpathSync(cwd); ; dir = path.dirname(dir)) {
28
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
29
+ if (path.dirname(dir) === dir) return null;
30
+ }
31
+ }
32
+
33
+ function gitDir(root) {
34
+ const dot = path.join(root, ".git");
35
+ if (fs.statSync(dot).isDirectory()) return dot;
36
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dot, "utf8"));
37
+ return m ? path.resolve(root, m[1].trim()) : null;
38
+ }
39
+
40
+ /** The commit HEAD names, read from .git (a worktree's commondir holds the shared refs). */
41
+ function headSha(root) {
42
+ const dir = gitDir(root);
43
+ if (!dir) return null;
44
+ const head = fs.readFileSync(path.join(dir, "HEAD"), "utf8").trim();
45
+ if (/^[0-9a-f]{40}$/.test(head)) return head;
46
+ const ref = (/^ref:\s*(.+)$/.exec(head) || [])[1];
47
+ if (!ref) return null;
48
+ const common = fs.existsSync(path.join(dir, "commondir"))
49
+ ? path.resolve(dir, fs.readFileSync(path.join(dir, "commondir"), "utf8").trim())
50
+ : dir;
51
+ for (const d of [dir, common]) {
52
+ const f = path.join(d, ref);
53
+ if (fs.existsSync(f)) return fs.readFileSync(f, "utf8").trim();
54
+ }
55
+ const packed = path.join(common, "packed-refs");
56
+ if (!fs.existsSync(packed)) return null;
57
+ const line = fs.readFileSync(packed, "utf8").split("\n").find((l) => l.endsWith(` ${ref}`));
58
+ return line ? line.split(" ")[0] : null;
59
+ }
60
+
61
+ function kitSettings(root) {
62
+ for (const kit of [".iKanban", ".i-kanban"]) {
63
+ const f = path.join(root, kit, "settings-local.json");
64
+ if (!fs.existsSync(f)) continue;
65
+ try {
66
+ return JSON.parse(fs.readFileSync(f, "utf8")) || {};
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
71
+ return {};
72
+ }
73
+
74
+ function taskKey(root) {
75
+ const own = (process.env.IKANBAN_TASK || "").trim();
76
+ if (/^[A-Za-z][A-Za-z0-9]{0,9}-\d+$/.test(own)) return own.toUpperCase();
77
+ let head = "";
78
+ try { head = fs.readFileSync(path.join(gitDir(root), "HEAD"), "utf8"); } catch { return undefined; }
79
+ const branch = (/^ref:\s*refs\/heads\/(.+)$/m.exec(head) || [])[1] || "";
80
+ const m = /(?:^|[/_-])([A-Za-z][A-Za-z0-9]{0,9}-\d+)(?=$|[/_-])/.exec(branch.trim());
81
+ return m ? m[1].toUpperCase() : undefined;
82
+ }
83
+
84
+ /** V13-T10: one result to iKanban; best effort, never in the agent's way. */
85
+ async function report(kit, body) {
86
+ if (!kit.api_url || !kit.api_key || !kit.team) return;
87
+ try {
88
+ const team = await teamId({ base: kit.api_url, token: kit.api_key, team: kit.team });
89
+ const res = await ikanbanFetch(`/api/teams/${encodeURIComponent(team)}/deploy-checks`, {
90
+ base: kit.api_url, token: kit.api_key, method: "POST", body, timeoutMs: 4000,
91
+ });
92
+ if (!res.ok) throw new Error(`iKanban answered ${res.status}`);
93
+ } catch (e) {
94
+ process.stderr.write(`iKanban deploy check: not reported (${e.message})\n`);
95
+ }
96
+ }
97
+
98
+ /** V16-T14: the team's deploy check from iKanban; null when it does not answer. */
99
+ async function appSettings(kit) {
100
+ const file = path.join(os.homedir(), ".ikanban", "deploy-check", `${String(kit.team).replace(/[^A-Za-z0-9-]/g, "_")}.json`);
101
+ let saved = null;
102
+ try {
103
+ saved = JSON.parse(fs.readFileSync(file, "utf8"));
104
+ } catch {
105
+ saved = null;
106
+ }
107
+ const mine = saved && saved.api_url === kit.api_url && saved.settings && typeof saved.settings.enabled === "boolean";
108
+ if (mine && Date.now() - saved.at < SETTINGS_TTL_MS) return saved.settings;
109
+ try {
110
+ const team = await teamId({ base: kit.api_url, token: kit.api_key, team: kit.team });
111
+ const res = await ikanbanFetch(`/api/teams/${encodeURIComponent(team)}/deploy-check-settings`, { base: kit.api_url, token: kit.api_key, timeoutMs: 3000 });
112
+ if (!res.ok) throw new Error(`iKanban answered ${res.status}`);
113
+ const d = ((await res.json()) || {}).data || {};
114
+ if (typeof d.enabled !== "boolean") throw new Error("no deploy check settings in the answer");
115
+ const settings = { enabled: d.enabled, commands: Array.isArray(d.commands) ? d.commands : [], build_url: d.build_url || undefined, site_url: d.site_url || undefined };
116
+ try {
117
+ fs.mkdirSync(path.dirname(file), { recursive: true });
118
+ fs.writeFileSync(file, JSON.stringify({ at: Date.now(), api_url: kit.api_url, settings }));
119
+ } catch {
120
+ // the saved copy only spares a request
121
+ }
122
+ return settings;
123
+ } catch (e) {
124
+ process.stderr.write(`iKanban deploy check: settings not read from iKanban (${e.message})\n`);
125
+ return mine ? saved.settings : null;
126
+ }
127
+ }
128
+
129
+ /** The deploy check in force: iKanban's when the kit can ask it, else the local block. */
130
+ async function settingsFor(kit) {
131
+ if (kit.api_url && kit.api_key && kit.team) {
132
+ const app = await appSettings(kit);
133
+ if (app) return app.enabled ? app : null;
134
+ }
135
+ return kit.deploy_check || null;
136
+ }
137
+
138
+ /** The shell command from any of the three agents' hook input. */
139
+ function commandOf(event) {
140
+ let args = event.tool_input || event.toolArgs || event.tool_args || {};
141
+ if (typeof args === "string") {
142
+ try { args = JSON.parse(args); } catch { return ""; }
143
+ }
144
+ return args && typeof args.command === "string" ? args.command : "";
145
+ }
146
+
147
+ /** C1: build_url comes from a settings file, so only https, or http to this laptop. */
148
+ function allowedUrl(raw) {
149
+ try {
150
+ const u = new URL(raw);
151
+ return u.protocol === "https:" || (u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname));
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ /** Security review: the site URL reaches the agent as text, so only an origin and a short plain path:
158
+ * no sign-in, query or fragment (the same rule as check_deploy_settings on the server). */
159
+ function cleanUrl(raw) {
160
+ if (typeof raw !== "string" || raw.length > 200 || /[\s\u0000-\u001f\u007f]/.test(raw) || !allowedUrl(raw)) return null;
161
+ const u = new URL(raw);
162
+ if (u.username || u.password || u.search || u.hash || u.hostname.length > 100 || !/^[A-Za-z0-9/._~-]{1,100}$/.test(u.pathname)) return null;
163
+ return u.origin + u.pathname;
164
+ }
165
+
166
+ async function bundleGuard(cfg, sha) {
167
+ const short = sha ? sha.slice(0, 7) : "";
168
+ if (!cfg.build_url) return "Bundle guard: no build_url in deploy_check, so the served build is unknown; read the site's build marker yourself before any check.";
169
+ if (!allowedUrl(cfg.build_url)) return `Bundle guard: not run: build_url must be https (or http on localhost): ${cfg.build_url}`;
170
+ if (!short) return "Bundle guard: could not read this repository's HEAD commit.";
171
+ let body;
172
+ try {
173
+ const res = await fetch(cfg.build_url, { redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "cache-control": "no-cache" } });
174
+ body = (await res.text()).slice(0, 4000);
175
+ } catch (e) {
176
+ return `Bundle guard: could not read ${cfg.build_url} (${e.message}). Read it again before any check of the site.`;
177
+ }
178
+ if (body.includes(short)) return `Bundle guard: PASS: ${cfg.build_url} serves commit ${sha.slice(0, 8)}, the one just deployed.`;
179
+ return `Bundle guard: STALE: ${cfg.build_url} does not name commit ${sha.slice(0, 8)} yet (it says: ${body.replace(/\s+/g, " ").slice(0, 120)}). Do not read or check the site until it does: wait for the deploy, then read the marker again.`;
180
+ }
181
+
182
+ async function main(input) {
183
+ let event = {};
184
+ try { event = JSON.parse(input || "{}"); } catch { return {}; }
185
+ if (!event || typeof event !== "object") return {};
186
+ const cwd = typeof event.cwd === "string" && fs.existsSync(event.cwd) ? event.cwd : process.cwd();
187
+ const root = repoRoot(cwd);
188
+ const kit = root ? kitSettings(root) : {};
189
+ const command = commandOf(event);
190
+ if (!command) return {};
191
+ const cfg = await settingsFor(kit);
192
+ if (!cfg || !Array.isArray(cfg.commands)) return {};
193
+ if (!cfg.commands.some((c) => typeof c === "string" && c.trim() && command.includes(c.trim()))) return {};
194
+ // Gemini CLI also calls AfterTool for a failed command; a failed deploy has nothing to check.
195
+ if (event.tool_response && event.tool_response.error) return {};
196
+ const sha = headSha(root);
197
+ const guard = await bundleGuard(cfg, sha);
198
+ const result = guard.startsWith("Bundle guard: PASS") ? "pass" : guard.startsWith("Bundle guard: STALE") ? "stale" : null;
199
+ const siteUrl = cleanUrl(cfg.site_url);
200
+ const common = { site: siteUrl || undefined, commit: sha || undefined, task: taskKey(root) };
201
+ if (result) await report(kit, { result, ...common });
202
+ await report(kit, { result: "intent", ...common });
203
+ const site = siteUrl ? ` Open "${siteUrl}"` : " Open the deployed site";
204
+ const text = `${guard}\nIntent check:${site} in the browser once the bundle guard passes, and show that the change does what the task asked (the screen, the number, the flow), on the page a user would open. Report what you saw; "deployed" alone is not a check.`;
205
+ // Copilot CLI reads a top-level additionalContext; Claude Code and Gemini CLI read hookSpecificOutput.
206
+ if (event.toolName !== undefined || event.toolArgs !== undefined) return { additionalContext: text };
207
+ return { hookSpecificOutput: { hookEventName: event.hook_event_name || "PostToolUse", additionalContext: text } };
208
+ }
209
+
210
+ let input = "";
211
+ process.stdin.on("data", (c) => { input += c; });
212
+ process.stdin.on("end", async () => {
213
+ let out = {};
214
+ try {
215
+ out = await main(input);
216
+ } catch (e) {
217
+ process.stderr.write(`iKanban deploy check: ${e.message}\n`);
218
+ }
219
+ process.stdout.write(`${JSON.stringify(out)}\n`);
220
+ process.exit(0);
221
+ });
@@ -64,16 +64,20 @@ function apiOrigin(base) {
64
64
 
65
65
  /**
66
66
  * fetch() to iKanban's API. opts: base (the signed-in api_url), token, method, body (sent as
67
- * JSON), timeoutMs. Refuses another host, an absolute or protocol-relative path, and redirects.
67
+ * JSON), timeoutMs, headers (only X-Trace-Token is passed on). Refuses another host, an absolute or protocol-relative path, and redirects.
68
68
  */
69
- async function ikanbanFetch(apiPath, { base, token, method = "GET", body, timeoutMs = 60000 } = {}) {
69
+ async function ikanbanFetch(apiPath, { base, token, method = "GET", body, timeoutMs = 60000, headers: extra = {} } = {}) {
70
70
  const origin = apiOrigin(base);
71
71
  if (typeof apiPath !== "string" || !apiPath.startsWith("/") || apiPath.startsWith("//")) {
72
72
  throw new Error(`"${apiPath}" is not a path on the iKanban API; the key was not sent`);
73
73
  }
74
74
  const url = new URL(apiPath, origin);
75
75
  if (url.origin !== origin) throw new Error(`"${apiPath}" leaves ${origin}; the key was not sent`);
76
+ // Plan 35: X-Trace-Token (a trace's write token) is the one extra header; nothing may replace the key's.
76
77
  const headers = { Authorization: `Bearer ${token}` };
78
+ for (const [k, v] of Object.entries(extra)) {
79
+ if (k.toLowerCase() === "x-trace-token" && typeof v === "string" && /^[A-Za-z0-9]{16,128}$/.test(v)) headers["X-Trace-Token"] = v;
80
+ }
77
81
  if (body !== undefined) headers["Content-Type"] = "application/json";
78
82
  let res;
79
83
  try {
@@ -93,4 +97,23 @@ async function ikanbanFetch(apiPath, { base, token, method = "GET", body, timeou
93
97
  return res;
94
98
  }
95
99
 
96
- module.exports = { runCommand, startCommand, ikanbanFetch, apiOrigin };
100
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
101
+
102
+ /** The team's id for API paths. Set up writes the team's key ("IKA") into settings-local.json,
103
+ * and the API's routes take the id, so a key is looked up among the caller's teams. */
104
+ async function teamId({ base, token, team }) {
105
+ if (UUID.test(String(team || ""))) return team;
106
+ const res = await ikanbanFetch("/api/teams", { base, token, timeoutMs: 5000 });
107
+ if (!res.ok) throw new Error(`iKanban answered ${res.status} for the team list`);
108
+ const json = await res.json();
109
+ const list = Array.isArray(json.data) ? json.data : (json.data && json.data.teams) || [];
110
+ // Security review: the key first; a name only when no key matches; never a guess between two.
111
+ const want = String(team || "").toLowerCase();
112
+ const byKey = list.filter((t) => String(t.identifier || "").toLowerCase() === want);
113
+ const hits = byKey.length ? byKey : list.filter((t) => String(t.name || "").toLowerCase() === want);
114
+ if (hits.length > 1) throw new Error(`more than one team is called "${team}"; put the team's id in settings-local.json`);
115
+ if (!hits.length || !UUID.test(String(hits[0].id))) throw new Error(`no team "${team}" for this key`);
116
+ return hits[0].id;
117
+ }
118
+
119
+ module.exports = { runCommand, startCommand, ikanbanFetch, apiOrigin, teamId };