= {
+ "storage blob download-batch": { flags: ["--destination", "-d"] },
+ "storage file download-batch": { flags: ["--destination", "-d"] },
+ "storage blob upload-batch": { flags: ["--source", "-s"] },
+ "storage file upload-batch": { flags: ["--source", "-s"] },
+ "storage fs directory download": { flags: ["--destination-path", "-d"] },
+ "storage fs directory upload": { flags: ["--source", "-s"] },
+ "webapp deploy": { flags: ["--src-path"] },
+ "functionapp deploy": { flags: ["--src-path"] },
+ "webapp deployment source config-zip": { flags: ["--src"] },
+ "functionapp deployment source config-zip": { flags: ["--src"] },
+ "logicapp deployment source config-zip": { flags: ["--src"] },
+ "webapp config container set": { flags: ["--multicontainer-config-file"] },
+ "webapp create": { flags: ["--multicontainer-config-file"] },
+ "container create": { flags: ["--file", "-f"] },
+ "container container-group-profile create": { flags: ["--file", "-f"] },
+ "aks command invoke": { flags: ["--file", "-f"] },
+ "ts export": { flags: ["--output-folder"] },
+ "appconfig kv export": { flags: ["--path"] },
+ // az opens these itself in custom code, and they always take a path (never inline), so they are
+ // checked unconditionally like the rest of this list — a write target need not exist yet, so the
+ // existing-path safety net below would miss it. `rest --output-file` writes the
+ // response; the `apim` ones read/write a spec or schema file.
+ rest: { flags: ["--output-file"] },
+ "apim api import": { flags: ["--specification-path"] },
+ "apim api export": { flags: ["--file-path", "-f"] },
+ "apim api schema create": { flags: ["--schema-path"] },
+};
+
+// ---------------------------------------------------------------------------
+// Policy data. Exported so tests and the cross-check can read the same source.
+// ---------------------------------------------------------------------------
+
+const deny = (
+ reason: string,
+ ...prefixes: string[][]
+): Array<{ match: string[]; reason: string }> => prefixes.map((match) => ({ match, reason }));
+
+/** Denied command groups and verbs. Prefix matches. */
+export const DENIED: Array<{ match: string[]; reason: string }> = [
+ ...deny("the CLI is already logged in to the emulator with a dummy account", ["login"]),
+ ...deny("this would break routing to the emulator", ["logout"], ["account", "clear"]),
+ ...deny(
+ "this would re-point the CLI away from the emulator",
+ ["cloud", "register"],
+ ["cloud", "unregister"],
+ ["cloud", "update"],
+ ["cloud", "set"]
+ ),
+ // `config get` is allowed below; every other `config`, plus `configure`/`init`, writes state.
+ ...deny("the tool manages the CLI configuration", ["config"], ["configure"], ["init"]),
+ ...deny(
+ "extensions are pre-installed; adding or updating one downloads from the internet",
+ ["extension", "add"],
+ ["extension", "update"],
+ ["extension", "remove"]
+ ),
+ ...deny(
+ "this downloads or installs software on this machine",
+ ["upgrade"],
+ ["bicep", "install"],
+ ["bicep", "upgrade"],
+ ["bicep", "uninstall"],
+ ["bicep", "list-versions"],
+ ["aks", "install-cli"] // also runs `setx path`, rewriting the real user PATH
+ ),
+ ...deny(
+ "Bicep registry modules cannot be reached from the local emulator",
+ ["bicep", "restore"],
+ ["bicep", "publish"]
+ ),
+ ...deny("this calls a Microsoft web service", ["find"], ["feedback"], ["survey"]),
+ ...deny("this needs an interactive session", ["interactive"], ["self-test"]),
+ ...deny(
+ "this opens a browser on this machine",
+ ["aks", "browse"],
+ ["webapp", "browse"],
+ ["containerapp", "browse"]
+ ),
+ ...deny(
+ "this opens a shell, tunnel or live stream",
+ ["webapp", "ssh"],
+ ["webapp", "create-remote-connection"],
+ ["container", "exec"],
+ ["container", "attach"],
+ ["containerapp", "exec"],
+ ["webapp", "log", "tail"],
+ ["appservice", "plan", "managed-instance", "instance", "connect"],
+ ["network", "bastion", "ssh"],
+ ["network", "bastion", "rdp"],
+ ["network", "bastion", "tunnel"]
+ ),
+ ...deny(
+ "this runs Docker or downloads tools on this machine",
+ ["acr", "check-health"],
+ ["acr", "helm"],
+ ["aks", "check-acr"],
+ ["storage", "copy"],
+ ["storage", "remove"],
+ ["storage", "blob", "sync"], // storage copy/remove/sync auto-install azcopy from aka.ms
+ ["backup", "restore", "files", "mount-rp"],
+ ["mysql", "flexible-server", "deploy"],
+ ["postgres", "flexible-server", "deploy"]
+ ),
+ // Denied outright until its local-source flag is wired from _params.py. With a
+ // local source it makes az run `docker build`/`push` on the shared engine.
+ ...deny("this builds and pushes images on the shared Docker engine", [
+ "cognitiveservices",
+ "agent",
+ "create",
+ ]),
+ ...deny(
+ "Azure DevOps is not emulated",
+ ["devops"],
+ ["boards"],
+ ["repos"],
+ ["pipelines"],
+ ["artifacts"]
+ ),
+];
+
+/** Verbs that survive a broader group denial. */
+export const ALLOWED_EXCEPTIONS: string[][] = [["config", "get"]];
+
+/** Flags that are refused, globally or for a specific command. */
+export const DENIED_FLAGS: Array<{ command?: string[]; flag: string; reason: string }> = [
+ { flag: "--follow", reason: "it streams output until killed" },
+ { flag: "--login-with-github", reason: "it opens a browser login" },
+ { command: ["webapp", "up"], flag: "--launch-browser", reason: "it opens a browser" },
+ { command: ["webapp", "up"], flag: "-b", reason: "it opens a browser" },
+ { command: ["webapp", "up"], flag: "--logs", reason: "it streams logs until killed" },
+ {
+ command: ["containerapp", "up"],
+ flag: "--source",
+ reason: "it builds an image on the shared Docker engine",
+ },
+ {
+ command: ["containerapp", "up"],
+ flag: "--repo",
+ reason: "it wires up a GitHub Actions build",
+ },
+ {
+ command: ["containerapp", "create"],
+ flag: "--source",
+ reason: "it builds an image on the shared Docker engine",
+ },
+ {
+ command: ["containerapp", "create"],
+ flag: "--repo",
+ reason: "it wires up a GitHub Actions build",
+ },
+];
+
+/** Flags a command must carry, or it is refused. */
+export const REQUIRED_FLAGS: Array<{
+ command: string[];
+ flag: string;
+ aliases?: string[];
+ reason: string;
+}> = [
+ {
+ command: ["acr", "login"],
+ flag: "--expose-token",
+ aliases: ["-t"],
+ reason:
+ "without `--expose-token` az runs `docker login` on this machine; re-run it with `--expose-token`",
+ },
+];
+
+/**
+ * Local hosts the URL rule and the egress guard both allow: one
+ * shared check in local-hosts.ts. Re-exported here for the policy's own tests.
+ */
+export { LOCAL_HOST };
+
+/** The ARM host whose absolute URLs are rewritten to a relative path (matched case-insensitively). */
+const MANAGEMENT_HOST = "management.azure.com";
+
+/** URL-target flags whose value az turns into a request or a download. */
+const REST_URL_FLAGS = new Set(["--url", "--uri", "-u"]); // only for the `rest` command
+const FETCH_URL_FLAGS = new Set(["--template-uri", "--multicontainer-config-file"]); // any command
+const PARAMETERS_FLAGS = new Set(["--parameters", "-p"]); // URL or file, depending on the value
+
+// ---------------------------------------------------------------------------
+// Denylist file parsing.
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse LOCALSTACK_AZ_DENYLIST_FILE: one command prefix per line, split on whitespace. Blank
+ * lines and `#` comments are ignored. The result is passed to evaluateAzCommand
+ * as opts.extraDenied, so the policy itself stays pure.
+ */
+export function parseDenylistFile(text: string): string[][] {
+ const prefixes: string[][] = [];
+ for (const rawLine of text.split(/\r?\n/)) {
+ const withoutComment = rawLine.replace(/#.*$/, "");
+ const words = withoutComment.trim().split(/\s+/).filter(Boolean);
+ if (words.length > 0) prefixes.push(words);
+ }
+ return prefixes;
+}
+
+// ---------------------------------------------------------------------------
+// Small helpers.
+// ---------------------------------------------------------------------------
+
+const START_TOKEN = /^[a-z][a-z0-9-]*$/;
+const HELP_TOKENS = new Set(["--help", "-h"]);
+const ARM_ID = /^\/(subscriptions|providers|tenants)\//i;
+const ABSOLUTE_URL = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
+
+function effectivePlatform(opts: PolicyOptions): NodeJS.Platform {
+ return opts.platform ?? process.platform;
+}
+
+/** True when a command's leading words match `prefix` exactly. */
+function hasPrefix(argv: string[], prefix: string[]): boolean {
+ if (prefix.length > argv.length) return false;
+ return prefix.every((word, index) => argv[index] === word);
+}
+
+/**
+ * Match an option token against a canonical flag, honouring argparse's unambiguous-prefix
+ * abbreviation for long flags (verified: `cloud list --out tsv` and `--que` both work). A token
+ * `--` with p.length >= 2 that is a strict prefix of a long flag counts as that flag; short
+ * `-x` flags match only exactly. When a prefix could match several path/URL flags we still apply
+ * the rule (argparse would reject it as ambiguous, so being stricter is safe).
+ */
+function flagMatches(tokenFlag: string, fullFlag: string): boolean {
+ if (tokenFlag === fullFlag) return true;
+ return (
+ fullFlag.startsWith("--") &&
+ tokenFlag.startsWith("--") &&
+ tokenFlag.length >= 4 && // "--" plus at least two characters
+ fullFlag.length > tokenFlag.length &&
+ fullFlag.startsWith(tokenFlag)
+ );
+}
+
+/** True when a token's flag matches any flag in the set (exact or an unambiguous prefix). */
+function matchesAnyFlag(tokenFlag: string, flags: Iterable): boolean {
+ for (const flag of flags) if (flagMatches(tokenFlag, flag)) return true;
+ return false;
+}
+
+interface ParsedOption {
+ flag: string;
+ /** The value packed into the token itself (`--flag=v`, `-fv`, `-f=v`), else undefined. */
+ value?: string;
+ /** How an inline value was attached, so a rewrite can rebuild the token. */
+ sep: "=" | "";
+}
+
+/** Split one option token into its flag and any attached value (no lookup of the next token). */
+function parseOption(token: string): ParsedOption {
+ if (token.startsWith("--")) {
+ const eq = token.indexOf("=");
+ if (eq >= 0) return { flag: token.slice(0, eq), value: token.slice(eq + 1), sep: "=" };
+ return { flag: token, sep: "" };
+ }
+ // A single-dash short option, possibly with a stuck or `=`-joined value (`-f/x`, `-f=/x`).
+ const flag = token.slice(0, 2);
+ const rest = token.slice(2);
+ if (rest === "") return { flag, sep: "" };
+ if (rest.startsWith("=")) return { flag, value: rest.slice(1), sep: "=" };
+ return { flag, value: rest, sep: "" };
+}
+
+/** The path-taking flags of the command in `argv`, from the generated table plus the supplement. */
+function commandFileFlags(argv: string[]): {
+ generic: Set;
+ greedy: Set;
+ params: Set;
+ positional: boolean;
+} {
+ const generic = new Set();
+ const greedy = new Set();
+ const params = new Set